Skip to content

Rotating Secrets

This page covers how to rotate each secret. For the reference of what each variable does and when you need to set it, see Environment variables.

Secrets are grouped by what happens if they leak. Rotate from the top down when a leak is suspected; rotate the critical tier on a cadence (suggested quarterly) even without one.

  1. Mint the new value at the source first (provider dashboard, openssl rand -base64 32, etc.). Keep the old value live.
  2. Stage the new value in your deploy env (Vercel project settings, Docker .env, etc.) without deleting the old.
  3. Trigger a redeploy. Vercel: push or vercel --prod. Docker: docker compose up -d.
  4. Verify the new value is in use by exercising a feature that depends on it (test email, run an Intelligence recommendation, claim an item).
  5. Revoke the old value at the source. This is the step that actually closes the window. Until you do this, the old credential is still live.

If anything in steps 3-4 fails, you can roll back by swapping the env back to the old value and redeploying - the old credential hasn’t been revoked yet.

Two ways to handle it:

Re-encrypt the encrypted rows under the new key.

  1. Mint the new value: openssl rand -base64 32.
  2. With the old BETTER_AUTH_SECRET still active, run a one-shot script that walks every encrypted app_settings.value JSONB envelope ({ v: 1, iv, tag, data }), decrypts under the old key, re-encrypts under the new key, writes back. Do it in a transaction.
  3. Stop the app. Swap BETTER_AUTH_SECRET. Start the app.
  4. Verify admin-managed secrets (Resend, AI, OIDC, scraper tokens) still work.

Faster, blunter. Loses the encrypted values; you’ll re-enter them in the admin UI.

  1. Stop the app.
  2. DELETE FROM app_settings WHERE value::text LIKE '{"v":1,%'; (or column-specific equivalent). Use DELETE, not UPDATE ... SET value = NULL — a NULL value will fail Zod parsing for rows whose schema requires an object (e.g. oidcClient), but an absent row falls back to the default.
  3. Swap BETTER_AUTH_SECRET. Start the app.
  4. Re-enter every admin-managed secret in the admin UI.

Both options force every signed-in user to re-authenticate (old session signatures are unverifiable under the new key). Schedule for a low-traffic window.

Full compromise if leaked. Rotate promptly on any suspected exposure.

Procedure: see “The BETTER_AUTH_SECRET gotcha” above. Don’t shortcut this one.

Who notices: every signed-in user (forced re-auth) plus any admin-saved secret (must be re-entered if you took Option B).

Rotate the Postgres password at the provider (Vercel Postgres / Neon / Railway / Supabase / self-host), update the env var, redeploy. Sessions survive because the auth layer doesn’t read DATABASE_URL directly.

Terminal window
# Self-host: change inside the running container, then restart the app.
psql "$DATABASE_URL" -c "ALTER USER giftwrapt WITH PASSWORD '<new>';"
# Update .env / Vercel env to match. Redeploy.

Who notices: anyone hitting the app during the redeploy (brief 500s).

STORAGE_SECRET_ACCESS_KEY (and matching STORAGE_ACCESS_KEY_ID)

Section titled “STORAGE_SECRET_ACCESS_KEY (and matching STORAGE_ACCESS_KEY_ID)”

Mint a new IAM credential at the storage provider (R2, AWS, Garage admin API, etc.), swap env, redeploy. The old credential stays valid until you explicitly delete it - leave it live for ~24 hours after the swap so you can confirm the new one is in use everywhere, then revoke.

Who notices: active uploads during the redeploy window may see transient failures.

Specific subsystem compromise. Rotate within a few days of a suspected leak.

If set via the admin UI (the recommended path), the value lives encrypted in app_settings and can be rotated without redeploy: revoke at the provider, mint a new key, paste it into the admin UI. If set via env, also bump the env and redeploy.

Who notices: Intelligence analyzers and admin AI tools. Other features are unaffected.

Same shape as AI_API_KEY: admin-UI rotation is preferred, env is a fallback. Revoke at resend.com, mint new, paste in. Email features are silently disabled until a working key is in place.

Who notices: birthday/Christmas/holiday reminder cron, comment notifications, password reset emails.

Rotate the env, redeploy, then update the caller (Vercel Cron config, GitHub Actions secret, whatever external scheduler is hitting /api/cron/*). Endpoints fail closed with 503 when unset, so a brief misconfig window is safe - the cron handler returns 503 until the caller is updated too.

For any provider configured under better-auth’s genericOAuth: rotate at the IdP, paste the new secret into the admin UI, restart the server.

Who notices: users in the middle of an SSO sign-in flow during the restart.

Rotate at the scraper service, swap the value (env or admin UI), redeploy or restart.

Who notices: item-extraction flows that go through the browserless provider.

Internal credentials. Rotate as part of normal hygiene.

Self-host only. New openssl rand -hex 32 each, update Docker .env, docker compose down && up. Garage RPC peers must share the same value, so if you scale to a multi-node Garage cluster you have to roll every node together.

Only relevant when using the bundled Postgres container. Update the env, run ALTER USER postgres WITH PASSWORD '<new>' inside the container, restart the app. Managed Postgres deployments don’t use this - they use DATABASE_URL directly.

Config, not credentials. Document the value somewhere durable; don’t rotate.

TierCadence
CriticalQuarterly
HighAnnual, or sooner on provider leak notification
OperationalWhen the storage backend or hosting setup changes

Any tier: rotate immediately on a known leak (committed credential, breached provider, departed contributor with access).

Keep a running log of rotations in a private location (we use core/.notes/security/rotation.md internally). Useful when investigating later: did we already rotate this when so-and-so was offboarded?

Each entry: YYYY-MM-DD - <secret> - <reason>. That’s it.