Orbit
06 · Deploy

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

SecretRotation costApproach
BETTER_AUTH_SECRETAll sessions invalidatedRotate only when compromised. Expect every user to re-auth.
STRIPE_WEBHOOK_SECRETStripe queues retriesRotate via Stripe dashboard; update env; redeploy.
POLAR_WEBHOOK_SECRETPolar queues retriesSame shape as Stripe.
DODO_PAYMENTS_WEBHOOK_KEYDodo queues retriesSame shape.
QSTASH_CURRENT_SIGNING_KEYZero downtimeShift current → next, set new next. Both are checked.
RESEND_API_KEYZero downtimeCreate new key, swap env, delete old key.
GOOGLE_CLIENT_SECRETBrief OAuth outageGoogle supports one secret at a time. Rotate during a low-traffic window.
APPLE_CLIENT_SECRETExpires every 6 monthsRegenerate from your .p8; automate it.
UPLOADTHING_TOKENZero downtimeNew token, swap env, redeploy.
DATABASE_URLvariesProvider-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.
Heads up

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:

  1. Upstash dashboard → rotate signing keys.
  2. Shift the env: old next becomes current; take the new key from Upstash as the new next.
  3. 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:

  1. Add a second webhook endpoint at the same URL in the provider dashboard.
  2. Copy the new secret; update the env var; redeploy.
  3. 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

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

TargetRecommended store
RailwayPer-service env vars in the dashboard. Use shared variables for things consumed by multiple services.
Flyfly secrets set — encrypted at rest, available as env vars in the VM.
VercelEnvironment Variables UI — scope to Production / Preview separately.
Self-hosted / KamalSOPS + age, or Vault, or AWS Secrets Manager.
AnywhereNot .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.

Note

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:

  1. Rotate the affected secret at the source (provider dashboard or openssl).
  2. Update your secret manager / deploy target env.
  3. Redeploy the API (and the web shells if the secret was a VITE_* — those are baked at build time).
  4. Grep the repo history (git log -p -S <partial>) to confirm the leak's blast radius.
  5. If it was BETTER_AUTH_SECRET: inform users that they'll need to sign in again.