Skip to content

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

TableColumns
payment_settingsstripe_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
webhookssecret
oauth_accountsaccess_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 from crypto/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_SECRET in /opt/packedge/.env on the deploy target.
  • Kept distinct from SESSION_SECRET so 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_secrets

The 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.