Appearance
Secrets at rest
Provider credentials, webhook secrets, and OAuth tokens are encrypted at rest with AES-256-GCM. A DB read alone is not enough to take over a connected Stripe or Polar account, or to ride a developer's GitHub / Google session.
What's covered
| Table | Columns |
|---|---|
payment_settings | stripe_secret_key, stripe_webhook_secret, stripe_test_secret_key, stripe_test_webhook_secret, polar_access_token, polar_webhook_secret, polar_test_access_token, polar_test_webhook_secret |
webhooks | secret |
oauth_accounts | access_token, refresh_token |
Identifiers that are public by design — Polar organization IDs, Stripe publishable keys, webhook URLs — are stored as plain text.
Format
Every encrypted value carries an explicit version tag:
enc:v1:<base64url(nonce || ciphertext || tag)>enc:v1:— version prefix. New versions will use a new tag rather than re-encrypting in place.nonce— 12 random bytes fromcrypto/rand, per write.ciphertext— AES-256-GCM output.tag— GCM authentication tag.
The tag lets reads route through the same call as plaintext, so a half-finished backfill is safe: untagged rows return as-is, tagged rows are decrypted transparently.
Master key
A single environment variable holds the key for the whole process:
bash
SECRET_ENCRYPTION_KEY=$(openssl rand -base64 32)- Must be 16, 24, or 32 bytes once base64-decoded — 32 is the recommended size.
- Required in production. The API panics on startup if it's missing or malformed.
- Stored alongside
SESSION_SECRETin/opt/packedge/.envon the deploy target. - Kept distinct from
SESSION_SECRETso rotating one doesn't invalidate the other.
Backfilling existing rows
After deploying the encryption shim, run the one-shot CLI to rewrite plaintext rows:
bash
cd apps/api
SECRET_ENCRYPTION_KEY=… DATABASE_URL=… go run ./cmd/encrypt_secretsThe CLI is idempotent — rerunnable, skips any row already carrying the enc:v1: prefix, and updates each row's secrets_encrypted_at timestamp. A -dry flag prints counts without writing.
Rotation
For v1 the rotation path is "redeploy with a new key and rerun the backfill against the old + new key in turn." A first-class rotation CLI is on the roadmap; the threat model in v1 is single-tenant compromise, where most operators never need to rotate.
What an attacker with raw DB access still sees
Encryption at rest protects the secret material itself; it doesn't hide that a particular row exists or what its surrounding metadata looks like (account ID, provider name, last-connected timestamp). For sensitive fingerprinting beyond the secret bytes, you still want DB-level access controls.
