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.
Use EncryptedTextField for a value Baserow must read back in plain text to send
somewhere else. Don’t use it for:
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.
| 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().
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.
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.
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:
flowchart TD
Read["Row loaded from the database"] --> Enc{"Starts with<br/>enc:v<n>:<key id>:?"}
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.
| 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 |
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:
reencrypt_secrets moves the module’s values to a new key.