Secrets & rotation
Which keys are load-bearing, what happens when you rotate each, and what you can do zero-downtime.
Orbit doesn't do anything clever with secrets — they're plain env vars read at boot time. What matters is knowing, per secret, what breaks when you rotate it and whether users get logged out.
The quick reference
| Secret | Rotation cost | Approach |
|---|---|---|
BETTER_AUTH_SECRET | All sessions invalidated | Rotate only when compromised. Expect every user to re-auth. |
STRIPE_WEBHOOK_SECRET | Stripe queues retries | Rotate via Stripe dashboard; update env; redeploy. |
POLAR_WEBHOOK_SECRET | Polar queues retries | Same shape as Stripe. |
DODO_PAYMENTS_WEBHOOK_KEY | Dodo queues retries | Same shape. |
QSTASH_CURRENT_SIGNING_KEY | Zero downtime | Shift current → next, set new next. Both are checked. |
RESEND_API_KEY | Zero downtime | Create new key, swap env, delete old key. |
GOOGLE_CLIENT_SECRET | Brief OAuth outage | Google supports one secret at a time. Rotate during a low-traffic window. |
APPLE_CLIENT_SECRET | Expires every 6 months | Regenerate from your .p8; automate it. |
UPLOADTHING_TOKEN | Zero downtime | New token, swap env, redeploy. |
DATABASE_URL | varies | Provider-specific. Neon/Supabase do user rotation in-UI. |
The load-bearing one: BETTER_AUTH_SECRET
Every session token in the database is signed with this. Rotate it and:
- Every active session fails to verify on the next request.
- Every user sees a redirect to
/login. - Magic link tokens issued under the old secret stop working.
Generate it once with openssl rand -hex 32. Treat it like a database password: store in a secret manager, limit who can read it, don't check it into git. If you leak it, rotate — even though every user gets kicked out.
QStash's rolling keys
QStash is the only secret in the stack with a built-in zero-downtime rotation story:
apps/api/.env (prod)
QSTASH_CURRENT_SIGNING_KEY="sk_current..."QSTASH_NEXT_SIGNING_KEY="sk_next..."The API verifies inbound webhook signatures against both keys. To rotate:
- Upstash dashboard → rotate signing keys.
- Shift the env: old
nextbecomescurrent; take the new key from Upstash as the newnext. - Redeploy. Zero dropped deliveries.
Billing webhook secrets
Stripe, Polar, and Dodo each hold one secret at a time per endpoint. The pattern is the same for all three:
- Add a second webhook endpoint at the same URL in the provider dashboard.
- Copy the new secret; update the env var; redeploy.
- Delete the old endpoint once the new one is verified.
Providers retry failed deliveries — even if a delivery lands on an API that has the wrong secret cached, it'll be retried against the updated instance. No lost events, but a brief window of signature failures in your logs during the swap.
OAuth client secrets
Google's console only lets you hold one active client secret per client. Strategy: do the rotation during a quiet window. GOOGLE_CLIENT_SECRET is only used during the OAuth callback — users already signed in via Google aren't affected; new sign-ins retry automatically.
Apple
Apple's "client secret" is a JWT you sign with your .p8 key. It expires at most every 6 months. Keep a script that regenerates it and writes to your secret manager; your deploy pipeline should pick it up and restart the API. If you forget, sign-in-with-Apple breaks silently while other providers keep working.
Where to store secrets
| Target | Recommended store |
|---|---|
| Railway | Per-service env vars in the dashboard. Use shared variables for things consumed by multiple services. |
| Fly | fly secrets set — encrypted at rest, available as env vars in the VM. |
| Vercel | Environment Variables UI — scope to Production / Preview separately. |
| Self-hosted / Kamal | SOPS + age, or Vault, or AWS Secrets Manager. |
| Anywhere | Not .env in git. Always. |
What's safe to commit
The .env.example files committed to the repo never hold real values. The dev-defaults (change-me-in-prod, http://localhost:*) are fine to check in. Everything provider-specific — API keys, webhook secrets, database URLs — lives only in the local .env, which .gitignore excludes.
If a secret ever lands in a commit: rotate it, then use git filter-repo (or a contained migration branch) to scrub history. Force-pushing an edited history has its own tradeoffs — talk to your team before doing it on main.
Incident checklist
Something leaked. In order:
- Rotate the affected secret at the source (provider dashboard or
openssl). - Update your secret manager / deploy target env.
- Redeploy the API (and the web shells if the secret was a
VITE_*— those are baked at build time). - Grep the repo history (
git log -p -S <partial>) to confirm the leak's blast radius. - If it was
BETTER_AUTH_SECRET: inform users that they'll need to sign in again.