Secret rotation runbook
When and how to rotate the secrets that gate session validity, token encryption, and webhook authenticity. Keep this short — read it before the rotation, not during.
When and how to rotate the secrets that gate session validity, token encryption, and webhook authenticity. Keep this short — read it before the rotation, not during.
Inventory
| Secret | Used by | Impact when rotated |
|---|---|---|
NEXTAUTH_SECRET | JWT signing for every user session and, when no dedicated TOKEN_ENCRYPTION_KEY is set, the root the AES-256-GCM token key is derived from (currentSecret() in src/lib/security/crypto.ts takes TOKEN_ENCRYPTION_KEY, then the canonical NEXTAUTH_SECRET). Also the fallback root of INTELLIGENCE_ANON_SECRET and VITALS_BEACON_SECRET. | Every active session invalidated (users log in again) and, absent a dedicated TOKEN_ENCRYPTION_KEY, every stored OAuth token stops decrypting under the current key — recoverable only by carrying the old value in TOKEN_ENCRYPTION_KEYS_LEGACY. This row said "JWT signing" and nothing else until 2026-09 : that reading is what replays backlog/_archive/security-identity/0004-crypto-key-drift-stale-tokens.md. |
TOKEN_ENCRYPTION_KEY | AES-256-GCM envelope for Connector.accessToken/refreshToken (Google, Meta, Notion, Shopify), IntegrationConnection.accessToken/refreshToken + metadata.clientSecretEncrypted, WhatsappChannel.{verifyToken,accessToken,appSecret}Encrypted, McpConnector.{authToken,clientId,clientSecret}Encrypted, VerifiedRevenue.encryptedToken, KycVerification.encryptedReport. There is no BYOK key store any more (retired at the AI Gateway migration) and no ProviderApiKey model — this row named both until 2026-09. | Rows encrypted under the previous key are recoverable, not lost : decryptToken tries every key from getCandidateKeys(). Carry the old value in TOKEN_ENCRYPTION_KEYS_LEGACY and the encrypt-tokens cron re-encrypts them. Rotate without it and connectors must be reconnected. |
TOKEN_ENCRYPTION_KEYS_LEGACY | Comma-separated retired encryption secrets, newest first — tried on decrypt only, never for a new write (crypto.ts:57-78) | Dropping an entry before the backfill reports zero legacy rows makes those rows unrecoverable. |
STRIPE_WEBHOOK_SECRET | HMAC verification on Stripe webhook events | Stripe webhooks return 400 until Stripe is updated with the new endpoint secret. |
WHATSAPP_APP_SECRET (+ per-channel appSecretEncrypted) | HMAC on Meta-signed inbound webhooks | WhatsApp inbound returns 401 until Meta re-pushes events signed with the matching secret. |
CRON_SECRET | Bearer token gate on every /api/cron/* route | All scheduled jobs return 401 until Vercel Cron is updated. |
TURNSTILE_SECRET_KEY | Cloudflare challenge on auth flows | Auth pages fail closed until Cloudflare is updated. |
KV_REST_API_TOKEN / UPSTASH_VECTOR_REST_TOKEN | Upstash auth | Rate-limit + memory layer go read-only / down until env is updated. |
RESEND_WEBHOOK_SECRET | Svix signature on /api/webhooks/resend | Delivery / bounce / complaint events return 401 until Resend is updated. Empty = every delivery is rejected, so nothing is recorded. |
QSTASH_TOKEN + QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY | Publishing background jobs, and verifying the signature on incoming job callbacks (services/jobs/verifier.ts tries both keys) | Enqueue fails and every job callback is rejected. Because both keys are tried, Upstash's own rollover is the safe path : the new value lands in NEXT first, then gets promoted to CURRENT. |
VITALS_BEACON_SECRET | Not a vitals-only secret : the per-store beacon sub-key and the HMAC of customer emails (shopify/ingester/lib/email-hash.ts) both derive from it. The readers are pinned by src/env/shared-secrets.test.ts. Falls back to NEXTAUTH_SECRET. | Destructive and silent. Rotating re-keys the customer-identity hash : rows written afterwards no longer join to the same shopper, with no error, no log, no alert. |
INTELLIGENCE_ANON_SECRET | Salt for anonymised store identifiers, and the key of the panel pseudonym /api/intelligence/panel/ingest derives from the session user (HMAC(anonSecret, "panel:" + userId)). Falls back to NEXTAUTH_SECRET (env/server.ts), so it moves whenever that secret moves. | Identifiers change. Rows anonymised before the rotation no longer match rows anonymised after it — silently, like the beacon secret above. |
EVI_CLM_SECRET | Shared secret the Hume CLM forwards to /api/evi/chat/completions (x-evi-secret or bearer) | Voice sessions return 401 until Hume is updated. Empty in production = 503, by design : the route is otherwise an unauthenticated token tap. |
TRACKING_SYSTEM_PASSWORD | Shared password compared (timing-safe) by /api/tracking/unlock, which mints the unlock cookie requireTrackingUnlock reads (src/app/(minimal)/setup/tracking/_lib/unlock-gate.ts). Since security-identity/3004 it gates the tracking system's home (/setup/tracking) and its interactive pages (GATED_SETUP_PAGES), NOT the guides under /setup/tracking/<guide>, which are public. | The unlock form rejects every password until the new value is circulated, and every existing unlock cookie stops verifying (it is an HMAC of the password). Empty = 500 on the form and a closed gate. |
When to rotate
| Trigger | Priority |
|---|---|
| Suspected leak (env var copy-pasted in Slack/Discord, repo accident, machine theft) | Within 1 hour. |
| Employee offboarding with access to env vars | 24 hours. |
| Annual hygiene rotation | Once per year. |
| New encryption requirement (e.g. SOC 2) | Per the requirement. |
NEXTAUTH_SECRET — full procedure (most common)
The JWT strategy means our sessions are not revocable individually. The only kill-switch is a secret rotation. Plan a maintenance window of ~5 minutes when every active user will be logged out.
Step 0 is not optional. If TOKEN_ENCRYPTION_KEY is unset, this secret
IS the token-encryption key, and steps 1-5 below make every stored OAuth
token undecryptable. The paragraph after step 5 says why ; this step is how
you never have to read it.
- Check whether a dedicated key exists :
TOKEN_ENCRYPTION_KEYset in production → nothing to do, go to 1.- unset → run the TOKEN_ENCRYPTION_KEY procedure below FIRST, with the
current
NEXTAUTH_SECRETcarried inTOKEN_ENCRYPTION_KEYS_LEGACY. It moves token encryption onto its own key and decouples the two rotations, permanently. Do it once, and this step is never needed again.
- Generate a new secret :
openssl rand -base64 64 - Update Vercel env vars in this order :
- Production :
NEXTAUTH_SECRET→ new value - Preview : same value (keeps preview previews logged in)
- Production :
- Trigger a redeploy on
main(Vercel handles the env propagation atomically). - Wait for the deploy to go live (~2 min). The instant the new build serves, every old JWT fails signature check → user lands on
/auth. - Smoke test : open an incognito window, complete an OTP login, verify a deep link page (e.g.
/[orgSlug]/~/memory) loads.
No DB migration, no code change. The JWT in old browser tabs just stops verifying on its next refresh.
But this rotation is not session-only. currentSecret() in
src/lib/security/crypto.ts derives the AES-256-GCM token key from
TOKEN_ENCRYPTION_KEY, then the canonical NEXTAUTH_SECRET. With no
dedicated TOKEN_ENCRYPTION_KEY, step 2 above also changes the encryption key
and every stored OAuth token stops decrypting under it — that is item 0004,
replayed. So before step 2, do one of :
- set a dedicated
TOKEN_ENCRYPTION_KEYfirst (see the section below), which decouples the two for good ; or - add the OLD secret to
TOKEN_ENCRYPTION_KEYS_LEGACYin the same env update, sodecryptTokenkeeps recovering those rows while theencrypt-tokenscron re-encrypts them.
INTELLIGENCE_ANON_SECRET and VITALS_BEACON_SECRET inherit the same root when
left unset : rotating this secret also moves anonymised handles and the
customer-identity hash. Until 2026-09 this paragraph read "No DB migration. No
code change." and stopped there, which is precisely the reading that breaks
connectors.
TOKEN_ENCRYPTION_KEY — destructive, plan ahead
decryptToken does not return null on the first key mismatch. It tries
every key from getCandidateKeys() (crypto.ts:57-78) — the current secret,
then TOKEN_ENCRYPTION_KEY, NEXTAUTH_SECRET, then each
comma-separated entry of TOKEN_ENCRYPTION_KEYS_LEGACY — and GCM's auth tag
makes that safe : a wrong key fails final(), it never yields a
plausible-but-wrong plaintext.
The staged migration this section used to prescribe is already shipped.
There is nothing to patch in crypto.ts, no TOKEN_ENCRYPTION_KEY_NEXT env var
exists anywhere in the repo, and the "one-shot script" is a cron. The old Path A
sent operators to write code that had been merged, and the old Path B offered a
connector wipe as an acceptable choice when step 1 below avoids it for free.
Procedure (the only one)
- Put the CURRENT secret — whatever
currentSecret()resolves to today :TOKEN_ENCRYPTION_KEYif set, otherwiseNEXTAUTH_SECRET— intoTOKEN_ENCRYPTION_KEYS_LEGACY, comma-separated, newest first. - Set
TOKEN_ENCRYPTION_KEYto the new value (openssl rand -base64 64). Always rotate onto a dedicated key : it decouples token encryption from the session secret, so the nextNEXTAUTH_SECRETrotation really is session-only. - Redeploy. Reads keep working immediately : new writes use the new key, old rows still decrypt through the legacy candidate.
- Let the backfill run, or trigger it :
GET /api/cron/encrypt-tokens(daily 02:30 UTC invercel.json) classifies everyConnectorandIntegrationConnectionrow withclassifyTokenand enqueues anencrypt-oauth-rowQStash job for eachplaintext/legacyone ;reEncryptIfLegacyrewrites it under the current key. The response carries a per-statusinventory. - When the inventory shows zero
legacyand zerounrecoverable, drop the old value fromTOKEN_ENCRYPTION_KEYS_LEGACYand redeploy.
What the cron does not sweep. It covers Connector and
IntegrationConnection only. WhatsappChannel, McpConnector,
VerifiedRevenue.encryptedToken and KycVerification.encryptedReport are read
through the same candidate-key path, so they keep working while the legacy key
is present — but nothing re-encrypts them. Keep the retired secret in
TOKEN_ENCRYPTION_KEYS_LEGACY for as long as those rows matter, or re-enter
them by hand.
If the old secret is genuinely gone
Rows whose key no longer exists anywhere are classified unrecoverable and the
cron logs cron.encrypt_tokens.unrecoverable_rows. The only remedy is a
reconnect : email each org owner a reconnect link per broken connector. This is
a recovery path after a mistake, not a rotation strategy.
Webhook secrets
STRIPE_WEBHOOK_SECRET and WHATSAPP_APP_SECRET rotations require a coordinated change on the third-party side :
- Generate new secret on Stripe / Meta.
- Update Vercel env with the new value.
- Redeploy.
- Within the same maintenance window, switch the third-party to send with the new secret.
Stripe specifically supports dual-signed events during rollover : keep the old endpoint webhook active alongside a new one with the new secret for ~24h, then delete the old one.
CRON_SECRET
Trivial : update the env, redeploy. Vercel Cron sends with whatever value is in env at trigger time, so no third-party coordination needed.
After any rotation
- Log the rotation in
audit-log(TODO : add automated row insertion). - Update the team's password manager / 1Password vault entry.
- Note the rotation date in this file so the next hygiene rotation has a baseline.