Skip to Content
ContributeBackendDatabase Field Encryption

Database Field Encryption

Sensitive credentials (endpoint auth tokens and client secrets, model provider API keys, user tokens, tool credentials) are encrypted at rest in PostgreSQL using field-level encryption. This protects against exposure through database dumps, backups, or direct read access; it is not a substitute for network access control, least-privilege DB users, or protecting the key.

Library: cryptography.fernet

Fernet provides authenticated symmetric encryption (AES-128-CBC with HMAC) and returns URL-safe base64 ciphertext suitable for storage in text columns. It is already a backend dependency.

The application encrypts and decrypts through helpers in rhesis.backend.app.utils.encryption (encrypt, decrypt, is_encrypted), which use a single cached Fernet instance built from the configured key.

Key management

The key is read from the DB_ENCRYPTION_KEY environment variable — 32 URL-safe base64-encoded bytes (Fernet’s standard format), e.g. ZmDfcTF7_60GrrY167zsiPd67pEvs0aGOv2oasOM92s=. It is a required setting with no default: the backend refuses to start if it is missing or not a valid Fernet key.

Generate a key with:

generate-key.sh
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Storage by environment:

  • Local development: .env (gitignored, never committed)
  • CI: GitHub Secrets
  • Deployed: Kubernetes secrets / GCP Secret Manager, injected as an environment variable

Operational rules:

  • Use the same key across all instances in an environment; use a separate key per environment.
  • Losing the key means permanent loss of access to the encrypted data. Back up production keys in more than one secure location.
  • Rotation is not automatic: the cached cipher is built once per process, so changing the key requires re-encrypting all stored values and restarting the backend.

Implementation

The EncryptedString SQLAlchemy TypeDecorator makes encryption transparent to application code — it encrypts on write and decrypts on read. Its backing type is String (unlimited by default, or String(length) when a length is passed).

encrypted-string-type.py
class EncryptedString(TypeDecorator):
    impl = String
    cache_ok = True

    def __init__(self, length=None, **kwargs):
        super().__init__(**kwargs)
        if length:
            self.impl = String(length)

    def process_bind_param(self, value, dialect):
        # Encrypt on write.
        return None if value is None else encrypt(value)

    def process_result_value(self, value, dialect):
        # Decrypt on read.
        return None if value is None else decrypt(value)

Use it as a column type:

encrypted-model-usage.py
from rhesis.backend.app.utils.encryption import EncryptedString

class Endpoint(Base):
    __tablename__ = "endpoint"
    auth_token = Column(EncryptedString(), nullable=True)
    client_secret = Column(EncryptedString(), nullable=True)

Reads fail loudly

decrypt does not fall back to the raw stored value. A value that cannot be decrypted — because it is still plaintext, or was encrypted with a different DB_ENCRYPTION_KEY — raises DecryptionError rather than leaking ciphertext or plaintext to callers. The one-time data migration must therefore run before any encrypted column is read.

Non-deterministic ciphertext and lookups

Fernet output is non-deterministic, so encrypted columns cannot be queried directly. For values that need indexed lookup (user tokens), a SHA-256 hash of the plaintext (hash_token) is stored alongside the encrypted value and used as the lookup key.

Encrypted columns

Encryption is applied in place; column types and names are unchanged. Columns currently using EncryptedString:

  • endpoint.auth_token, endpoint.client_secret, endpoint.last_token
  • model.key
  • token.token
  • tool.credentials

Fernet adds roughly 40-60 bytes plus the base64-expanded plaintext, well within the existing text columns.

Migration

Existing plaintext values are encrypted by Alembic revision da9164715ec2:

run-migration.sh
alembic upgrade head

It processes endpoint, model, and token in batches and is idempotent — values already in Fernet format (prefix gAAAAA) are skipped, so it is safe to re-run. The downgrade reverses it, decrypting values back to plaintext for rollback only.

For diagnosing problems, see Encryption Troubleshooting and the broader Security notes.

References