Status: AcceptedArea: BackendDate: 2026-07-06
Context
Secrets we persist — OAuth tokens, OAuth2 client secrets, Slack secrets — used to live in plainString/Text columns, so any database-level leak (a dump, a replica, a backup, a
SQL-injection read) exposed them directly. We now encrypt them, and this ADR fixes how a
secret column is modelled so every new one is done the same way. The
secrets-encryption design covers the threat
model, KMS mechanics, key rotation, and migration; this ADR is the column-shape rule.
Decision
A secret is never a plain-text column. Choose the column(s) from how the secret is read:- Read by row id only → one
EncryptedStringcolumn. Map it withEncryptedStringType(polar/kit/encryption.py). It stores the envelope ciphertext (data key wrapped by a KMS master key in prod/sandbox, a static key in local/CI); loading a row does no crypto, and decryption is an explicitawait value.decrypt(id=...). The column’s context ({table, column}) plus the rowidbind each ciphertext to its row. - Compared synchronously or looked up by value → a
*_hashcolumn. Non-deterministic ciphertext can’t beWHERE-matched or compared without decrypting, so store a deterministic keyed hash (get_token_hash, HMAC-SHA256 withsettings.SECRET) and query/compare against it. - Both (looked up and revealable) → a hybrid:
*_hashand*_encrypted. The hash serves verification and lookup; the envelope column reveals the plaintext.
encrypt_*, hash_secret, set_*), never by hand-rolling crypto at the call site.
Consequences
- A database-only leak yields ciphertext or hashes, not usable secrets; the KMS key never sits in the database.
- New rule when adding a secret column: decide the access shape first, then pick
EncryptedString,*_hash, or both — and reuse the model helpers. encrypt/decryptare async (a KMS call), so they can’t run in synchronous code paths (e.g. authlib’s flow). There, dual-write the*_hashsynchronously and fill the*_encryptedcolumn from a backfill, accepting a temporary window where the ciphertext isNULL.- More columns and write paths per secret than a single plain column — deliberate.
Alternatives considered
sqlalchemy-utilsEncryptedType: keeps the key in the app, with no rotation or audit log.- In-house symmetric key in the app or env: the key sits with the data it protects — local and CI only.
- A single hash column for everything: one-way, so a secret that must be revealed again (e.g. an OAuth2 client secret re-read via RFC 7592) can’t use it — hence the hybrid.
- AWS Secrets Manager: per-secret cost is prohibitive at our row counts.
References
- Design doc: Encrypting secrets at rest.
- Primitives:
polar/kit/encryption.py(EncryptedString,EncryptedStringType),polar/kit/crypto.py(get_token_hash). - Applications:
OAuthAccount,SlackApp—EncryptedStringcolumns;OAuth2Client—*_hash+*_encryptedhybrid.

