> ## Documentation Index
> Fetch the complete documentation index at: https://handbook.polar.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR-0008: Encrypt secrets at rest

> How to model a secret column: envelope-encrypted by default, a keyed hash when it is compared or looked up by value.

<Info>
  **Status**: Accepted

  **Area**: Backend

  **Date**: 2026-07-06
</Info>

## 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](/engineering/design-documents/secrets-encryption) 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](/engineering/design-documents/secrets-encryption).
* Primitives: `polar/kit/encryption.py` (`EncryptedString`, `EncryptedStringType`),
  `polar/kit/crypto.py` (`get_token_hash`).
* Applications: `OAuthAccount`, `SlackApp` — `EncryptedString` columns; `OAuth2Client` —
  `*_hash` + `*_encrypted` hybrid.
