Storing secrets with EncryptedTextField

This guide is for developers who store a secret Baserow has to send to another service, such as an API key, an OAuth token or a password for an external database. The decision behind it is in ADR 015. How admins set and change the key is in the installation docs.

Only the four MCP server credentials use the field so far. Every other stored secret is still plain text until its module moves over.

When to use it

Use EncryptedTextField for a value Baserow must read back in plain text to send somewhere else. Don’t use it for:

  • values Baserow only compares, such as user passwords or API tokens it issues. Hash those.
  • values Baserow looks up or filters by. Each save stores a different ciphertext, so the database can’t match them.

Usage

from baserow.core.fields import EncryptedTextField


class SlackBotIntegration(Integration):
    token = EncryptedTextField(
        context="integrations.slackbotintegration.token",
        blank=True,
        default="",
        db_default="",
    )

The rest of the code reads and assigns plain strings. Only the database row holds ciphertext, as enc:v1:<key id>:<data>.

sequenceDiagram
    participant Code as Handler or serializer
    participant Field as EncryptedTextField
    participant DB as PostgreSQL

    Code->>Field: save "sk-live-123"
    Field->>Field: AES-256-GCM with the main key,<br/>a fresh nonce and the column's context
    Field->>DB: enc:v1:e5f6a7b8:pX3k…Qz9w
    DB-->>Field: enc:v1:e5f6a7b8:pX3k…Qz9w
    Field->>Field: pick key e5f6a7b8 from the key ring,<br/>decrypt with the same context
    Field-->>Code: "sk-live-123"
Part Example What it’s for
Marker enc: With the version and key id, the whole enc:v<n>:<key id>: prefix marks a value as encrypted, even when the rest is damaged. Anything else, such as enc:abc, is plain text saved before the column was encrypted.
Version v1 Lets the format change later.
Key id e5f6a7b8 First 8 hex characters of the key’s SHA-256, so reading never tries every key.
Data pX3k…Qz9w Base64url of the 12-byte nonce, the ciphertext and the 16-byte tag.

For an integration, also list the field in its type’s secret_fields, so the API never returns it, and in sensitive_fields, so workspace exports and the action log leave it out.

context binds every stored value to this column, so a value copied into another column can’t be decrypted. Follow app_label.model.field, keep it unique per column, and never change it, also when the app, model or field is renamed. Values stored under the old context can’t be decrypted any more.

Moving an existing column to the field is a migration that changes only the field class. The column stays text and rows aren’t rewritten. Existing plain text is read as is and encrypted on its next save, or by reencrypt_secrets. Ciphertext is longer than the secret, so a CharField with a max_length has to become a text column first.

A column that a released version already uses can’t rely on the field being new. During a deploy, old processes read the ciphertext as the secret and may send or clear it. Plan the rollout like the MCP development-build upgrade in the installation docs: check what’s stored, stop old processes before new ones start, and don’t roll back below the change.

What the database can do with it

Operation Allowed Why
Read, assign, save, bulk_update(), update() with a plain value Yes Values are encrypted on the way in and decrypted on the way out.
update() with an expression, such as Value() or F() Needs review Django skips the field’s preparation for expressions, so Value("x") stores plain text and F() copies another column’s ciphertext, which can’t be decrypted here.
Raw SQL, or a db_default that isn’t empty Needs review Neither goes through the field, so the value is stored as plain text.
filter(token__isnull=True) Yes The only lookup. It only matches NULL: empty secrets are stored as "", and no lookup finds those.
filter(token="abc"), startswith, any other lookup No Raises an error instead of quietly matching nothing.
unique=True, db_index=True No System check baserow.E001 stops Baserow at startup.
Ordering or grouping by the column Pointless Not blocked, but ciphertext order and groups mean nothing.
dumpdata and loaddata Yes Dumps keep values encrypted and load them back once.

Assigning a ciphertext and saving it encrypts it again; it is never decrypted. Only to_python, which Django uses to load dumps, decrypts its input, so never pass values a user sent through it, for example with full_clean().

Where the keys come from

Baserow builds one key ring from the settings. One key encrypts; every other key can only decrypt values that name it.

flowchart LR
    EK["BASEROW_ENCRYPTION_KEY"] -->|set| Main
    SK["SECRET_KEY"] -->|"used when the key above isn't set"| Main
    Main["Main key<br/>encrypts and decrypts"]

    FB["BASEROW_ENCRYPTION_KEY_FALLBACKS<br/>old encryption keys or old SECRET_KEYs"] --> Old
    SKF["SECRET_KEY and SECRET_KEY_FALLBACKS"] --> Old
    Old["Read-only keys<br/>decrypt only"]

    Main --> Ring(("Key ring<br/>keys by id"))
    Old --> Ring

Every value is stretched to 32 bytes with HKDF-SHA256, with a different label for encryption keys and for SECRET_KEY, so the same string set as both still gives two keys. A BASEROW_ENCRYPTION_KEY shorter than 32 characters, or two keys with the same id, stop Baserow at startup. A shorter value in the fallbacks is accepted and treated as an old SECRET_KEY.

Changing the key

The key changes in stages, so every running process can read what the others write. Each stage restarts every Baserow service and waits until no process runs the previous config. The installation docs have the full steps for admins, including rolling back part way through.

sequenceDiagram
    participant Admin
    participant Baserow as Every Baserow service
    participant DB as Stored secrets

    Admin->>Baserow: 1. add B to the fallbacks, A stays main, restart all
    Note over Baserow,DB: every process reads A and B,<br/>saves still use A
    Admin->>Baserow: 2. make B main, move A to the fallbacks, restart all
    Note over Baserow,DB: values on A still read through A,<br/>new saves use B
    Admin->>Baserow: 3. reencrypt_secrets
    Baserow->>DB: decrypt every value, save it with B
    Admin->>Baserow: 4. reencrypt_secrets --check
    Baserow-->>Admin: counts per key, error if any value can't be read
    Admin->>Baserow: 5. remove A from the fallbacks, restart all

Without step 1, a process still running with only A can’t read what an already restarted process saved with B.

A secret that can’t be decrypted

When no configured key can decrypt a value, for example after a key changed without the old one in the fallbacks, reading the row still works. The field returns a stand-in that:

  • is truthy, so checks such as “is a credential set?” still see it, and a change that clears the credential still clears it;
  • has empty text, which must never be sent;
  • writes the original ciphertext back when the row is saved unchanged, so it works again once the key is back.
flowchart TD
    Read["Row loaded from the database"] --> Enc{"Starts with<br/>enc:v&lt;n&gt;:&lt;key id&gt;:?"}
    Enc -->|no| Plain["Returned as is:<br/>empty, or plain text from before"]
    Enc -->|yes| Key{"Key id in the key ring?"}
    Key -->|no| Stand["Stand-in: truthy, empty text,<br/>warning logged"]
    Key -->|yes| Tag{"Data intact and decrypts<br/>with this column's context?"}
    Tag -->|no| Stand
    Tag -->|yes| Secret["The secret"]
    Stand --> Send(["Code sends it"])
    Send --> Raise["is_unreadable_secret is true:<br/>raise, keep the stored value"]
    Stand --> Save(["Row saved, field unchanged"])
    Save --> Keep["Original ciphertext written back"]

Code that sends the secret must check for it first and fail:

from baserow.core.fields import is_unreadable_secret

if is_unreadable_secret(integration.token):
    raise SlackBotCredentialUnavailable()

Never treat it as a missing secret, and never clear or overwrite it because it can’t be read. The MCP integration raises MCPServerCredentialUnavailable when connecting or refreshing a token, and keeps the stored value.

Failure states

Situation What happens Fix
Key changed, old key not in the fallbacks Secret can’t be used, a warning names the key id it needs Add the old key to the fallbacks
Damaged value: right prefix, broken data Secret can’t be used, kept unchanged on save, --check and rotation fail Enter the secret again
Value copied to another column or edited Secret can’t be used, a warning says it can’t be decrypted Enter the secret again
Key lost and no backup Every secret encrypted with it can’t be used Enter the secrets again
reencrypt_secrets meets a value it can’t read Moves the rest, then exits with an error and the count Add the missing key and run it again
Key too short, or two keys with the same id System check baserow.E002 stops Baserow at startup Use another key
No key configured A key derived from SECRET_KEY is used and a warning is logged once Set a dedicated key

Tests every adopter keeps

The shared field is covered in backend/tests/baserow/core/test_encryption.py. A module that adopts it adds tests for its own code paths:

  • the secret is stored encrypted and reads back as the original;
  • a value that can’t be decrypted still loads the row and is never sent: the code that uses it raises an error instead;
  • a value that can’t be decrypted is kept by a save, a token refresh or any background job, and only cleared by an action that would clear a readable one too;
  • checks that ask whether a secret is set, such as asking for it again when the target URL changes, still see a value that can’t be decrypted;
  • the API never returns the secret, encrypted or not;
  • workspace exports leave the secret out, and importing them works without it;
  • reencrypt_secrets moves the module’s values to a new key.