Skip to main content
Status: AcceptedArea: BackendDate: 2026-07-06

Context

Secrets we persist — OAuth tokens, OAuth2 client secrets, Slack secrets — used to live in plain String/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 EncryptedString column. Map it with EncryptedStringType (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 explicit await value.decrypt(id=...). The column’s context ({table, column}) plus the row id bind each ciphertext to its row.
  • Compared synchronously or looked up by value → a *_hash column. Non-deterministic ciphertext can’t be WHERE-matched or compared without decrypting, so store a deterministic keyed hash (get_token_hash, HMAC-SHA256 with settings.SECRET) and query/compare against it.
  • Both (looked up and revealable) → a hybrid: *_hash and *_encrypted. The hash serves verification and lookup; the envelope column reveals the plaintext.
Add the columns nullable and roll out per column without downtime — dual-write, backfill, cut reads over, drop the plain column (design doc, Appendix C). Set the columns through the model helpers (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/decrypt are async (a KMS call), so they can’t run in synchronous code paths (e.g. authlib’s flow). There, dual-write the *_hash synchronously and fill the *_encrypted column from a backfill, accepting a temporary window where the ciphertext is NULL.
  • More columns and write paths per secret than a single plain column — deliberate.

Alternatives considered

  • sqlalchemy-utils EncryptedType: 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, SlackAppEncryptedString columns; OAuth2Client*_hash + *_encrypted hybrid.