You rotate a leaked API key without downtime by creating the replacement before you revoke the old one, deploying the new value everywhere it is read, confirming the new key works, and only then expiring the original. Do it in the other order and you take an outage. Do it carelessly with a Supabase key and you sign out every logged-in user at once.
That last part surprises people. The fast, obvious move after a leak is to revoke the compromised key immediately. For most providers that is fine. For the legacy Supabase keys it is a trap, because those keys are not independent secrets you can swap. They are tied to the one secret that also signs every user session. This post is the operational runbook: the exact order, per provider, with the failure modes that turn a five-minute fix into a support queue.
It is the companion to the exposed API keys guide, which covers how keys leak in the first place. Here we assume the leak already happened and the only question left is how to roll the key cleanly.
TL;DR:
- Create before you revoke. Generate the new key, deploy it everywhere, verify it works, then expire the old one. The overlap window is what buys you zero downtime.
- Legacy Supabase keys can't be rotated in isolation. The
anonandservice_rolekeys are JWTs signed by one shared secret, and rotating that secret "immediately" signs out every active user [1]. Migrating to the newsb_secret_keys is what makes independent rotation possible [3]. - A Stripe key and its webhook signing secret are two different secrets. Rotating the API key does not touch the endpoint signing secret, so a careless rotation leaves your webhook returning 400 on every event.
- Rotated does not mean live. On Vercel, env-var changes "only apply to new deployments" [5], so the old key keeps working until you redeploy.
Table of contents
- What is the right order to rotate a leaked API key?
- How do you rotate a Supabase key without logging everyone out?
- How do you rotate a Stripe secret key without breaking webhooks?
- How do you rotate a Resend key during a cutover?
- Why is the leaked key still working after you rotated it?
- How do you confirm the leaked key is fully closed?
- Five ways API key rotation goes wrong
- Make rotation a procedure, not a panic
What is the right order to rotate a leaked API key?
The safe order is create, deploy, verify, then revoke. You generate the replacement key while the old one is still valid, push the new value into every environment that reads it, confirm the new key actually serves traffic, and only then expire the original. The overlap window where both keys work is the whole point: it removes the gap that causes an outage.
There is one exception, and it matters. If the key is being actively abused right now (you are watching the bill climb or seeing requests you did not make), you revoke first and accept the downtime. A short outage beats an open faucet. The zero-downtime sequence is the default for "we found this in a public repo," not for "someone is spending our money this minute."
Here is the sequence for a normal, not-actively-exploited leak:
- Detect and scope. Identify exactly which key leaked, where it ended up (git history, a client bundle, a log, a screenshot), and what it can reach. A
service_rolekey that bypasses Row Level Security is a different severity than a publishable key. - Create the replacement. Generate a new key at the provider. Do not delete the old one yet.
- Deploy the new value everywhere. Update the environment variable in every environment and trigger a deployment so the change actually propagates (see the redeploy gotcha below).
- Verify the new key serves traffic. Hit a path that uses the key. Watch the provider dashboard for requests authenticated by the new key.
- Expire or revoke the old key. Now that the new key is confirmed live, kill the original. Use the provider's expiry window if it has one.
- Audit. Check logs for any use of the old key after cutover, confirm the leak path is closed (force-push the git history, purge the log), and write down what happened.
Every provider below slots into this same six-step spine. What changes is the mechanics of steps 2 and 5, and that is where the per-provider traps live.
How do you rotate a Supabase key without logging everyone out?
The legacy anon and service_role keys cannot be rotated on their own, because they are JWTs signed by a single shared secret that also signs every user session. Supabase documents that the original design used "a single shared secret key to sign all JWTs," and that "this includes the anon and service_role keys, all user access tokens" [1]. Rotating one means rotating that secret, and when the secret is regenerated, "all current API secrets will be immediately invalidated, and all connections using them will be severed" [2].
In plain terms: the old way to rotate a leaked service_role key was to regenerate the project JWT secret, and doing that signs out everyone who is currently logged in. The signing-keys docs say it directly for the legacy path: "Currently active users get immediately signed out" [1]. That is not a bug you can code around. It is structural to symmetric, shared-secret signing.
The fix is the 2025 key system. Supabase now issues publishable keys (sb_publishable_...) and secret keys (sb_secret_...) that are independent of JWT signing, alongside asymmetric signing keys for sessions [3]. With that model, "no users get signed out" during rotation, because "non-expired access tokens will remain to be accepted, so no users will be forcefully signed out" [1]. You create a new secret key, swap it in, and revoke the old one without touching a single session. The legacy keys are deprecated by the end of 2026 [3], so this migration is not optional for much longer.
To rotate a new-style secret key, the docs are blunt: "create a new secret API key, then replace it with the compromised key" [3]. In a Next.js app, that secret only ever lives server-side. SecureStartKit reads it through createAdminClient() and never imports it into a client component, which is the backend-only data access pattern that keeps a service_role-class key out of the browser bundle in the first place:
// lib/supabase/server.ts (shape)
// The secret key is read from the environment, server-side only.
// Rotating it is a one-line env swap when you are on the new sb_secret_ keys.
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // swap to the new sb_secret_ value here
{ auth: { autoRefreshToken: false, persistSession: false } }
)
}
Migrating is a dashboard operation, not a code rewrite. In Settings > API Keys you enable the new publishable and secret keys, and both the legacy and new keys keep working during the transition. So you swap your server-side SUPABASE_SERVICE_ROLE_KEY value for an sb_secret_ key, deploy, verify, and disable the legacy keys once nothing depends on them. Pair that with the asymmetric JWT signing keys so sessions verify against a public key that key rotation never touches, which is the mechanism behind "non-expired access tokens will remain to be accepted" [1].
If you are still on the legacy keys and a service_role key leaks today, you have two honest options: accept the sign-out to rotate the JWT secret now, or migrate to the new keys first and rotate cleanly. Pick based on how exposed the key is. A key sitting in public git history for a week warrants the migration-then-rotate path; a key being actively used against you warrants the immediate, everyone-gets-signed-out rotation.
How do you rotate a Stripe secret key without breaking webhooks?
Use Stripe's built-in rotation, but remember that your secret API key and your webhook signing secret are two separate secrets. Stripe lets you rotate a key from the dashboard: "Rotating an API key revokes it and generates a replacement key that's ready to use immediately. You can also schedule an API key to rotate after a certain time" [4]. That scheduling is your overlap window.
When you rotate, Stripe shows an Expiration dropdown: "If you choose Now, the old key is deleted. If you specify a time, the remaining time until the key expires displays below the key name" [4]. Pick a time, not Now. Deploy the new sk_ key, confirm checkout still works, and let the old key expire on the timer. That is zero-downtime rotation handed to you by the platform.
Here is the part people miss. Rotating the STRIPE_SECRET_KEY does nothing to the webhook endpoint signing secret (whsec_). They are independent. If you rotate the API key and assume your webhooks are "refreshed," you have changed nothing about signature verification. And the reverse is the real outage: if you rotate or regenerate the endpoint signing secret without updating STRIPE_WEBHOOK_SECRET, every incoming event fails verification. The handler reads the raw body and the signing secret directly:
// app/api/webhooks/stripe/route.ts
const body = await request.text() // raw body, required for verification
const sig = headersList.get('stripe-signature')
let event: Stripe.Event
try {
event = getStripe().webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET! // the whsec_ secret, NOT the sk_ key
)
} catch (err) {
// Wrong or stale whsec_ lands here on every event: 400, silently dropped fulfillment.
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
A mismatch here fails loudly with a 400 on every event, which sounds better than failing silently until you notice the symptom: orders stop being fulfilled, delivery emails stop sending, and the Stripe dashboard fills with failed webhook attempts. Rotate the two secrets as a pair, deploy, then send a test event before you expire anything. The mechanics of that signature check are covered in depth in the Stripe webhook signature verification guide.
How do you rotate a Resend key during a cutover?
Resend keys can't be edited, so rotation is always create-new-then-delete-old, and you can run two keys at once during the swap. The docs state plainly: "You cannot view or edit an API Key value after it has been created" [6]. You can change the name, permission, and domain, but never the secret itself. So the only way to "rotate" a Resend key is to mint a new one and remove the old.
That actually makes the overlap window easy. Resend notes that "you can use multiple keys to isolate different application actions to different API Keys" [6], which means nothing stops you from having the old and new key valid at the same time. Create the new key (it starts with re_, the prefix the template validates at boot), deploy it as RESEND_API_KEY, confirm a transactional email sends, then delete the old key from the dashboard. There is no platform timer like Stripe's, so you control the overlap by simply not deleting the old key until the new one is confirmed live.
One small discipline pays off here: scope keys narrowly. A sending-only key that leaks is a smaller incident than a full-access key, and per-component keys mean you rotate one without disturbing the others.
Why is the leaked key still working after you rotated it?
Because changing an environment variable does not retroactively update deployments that are already running. On Vercel, "any change you make to environment variables are not applied to previous deployments, they only apply to new deployments" [5]. You updated the value in the dashboard, but the live deployment is still running with the old key baked into its environment until you ship a new deployment.
This is the single most common reason a rotation "doesn't work." You swap the value, you do not redeploy, and the old (leaked) key keeps authenticating traffic because the running process never saw the change. The fix is to trigger a deployment after every env-var change, then verify against the new deployment, not the old one.
A second, subtler version of this bites long-running server processes. If your app reads and caches an env var once at startup, the cached value outlives the dashboard change until the process restarts. The template validates and memoizes its environment at first access:
// lib/env.ts
let _env: Env | undefined
export function getEnv(): Env {
if (!_env) {
_env = envSchema.parse(process.env) // parsed once, then cached for the process lifetime
}
return _env
}
The memoization is good for performance and bad for assumptions: until that process is replaced by a fresh deployment, getEnv() keeps handing back the old value. The mechanisms behind env-var propagation, build-time inlining, and runtime resolution are the subject of the Next.js environment variable leak prevention guide; the rotation lesson is narrower: treat "redeployed and verified" as the definition of done, never "saved in the dashboard."
How do you confirm the leaked key is fully closed?
A leaked key is closed only when two things are both true: the new key serves all traffic, and the old key can no longer authenticate anywhere. Deleting the file that held the secret does neither. If the key was committed to git, the value lives in the repository history until you rewrite that history, and any clone made before your fix still carries it. Treat a key that was ever public as permanently burned.
That is why rotation is mandatory even after you scrub the source. You cannot prove nobody copied the value during the exposure window, so the only safe assumption is that someone did. Containment is three concrete checks:
- Rewrite git history, do not just delete the file. A commit that removes the key leaves the old value in every earlier commit. Use a history-rewriting tool (git filter-repo or BFG Repo-Cleaner) to strip the secret from all commits, force-push, and have collaborators re-clone. A "remove key" commit on top of the leak is cosmetic.
- Read the provider's request logs. Supabase, Stripe, and Resend each surface recent API activity. Look for calls you did not make between the leak and the cutover. Evidence of use turns a precautionary rotation into an active incident with a faster, accept-the-downtime response.
- Prove the old key is dead. After cutover, make a deliberate request with the old key, or watch for any traffic still using it. A revoked key should fail. If it still authenticates, you missed an environment or skipped the redeploy.
The uncomfortable rule of thumb: absence of misuse in the logs is not proof of safety, only the absence of proof of harm. Rotate anyway.
Five ways API key rotation goes wrong
Most rotation incidents are one of five mistakes. Each maps to a step in the runbook that got skipped or done out of order.
- Rotating the legacy Supabase JWT secret to kill one key. Regenerating the shared secret signs out every active user and severs every connection using the old secrets [1][2]. The cause is treating the
service_rolekey as an independent secret when it is a JWT bound to the signing secret. Fix: migrate to thesb_secret_keys so you can rotate without touching sessions [3]. - Rotating the Stripe API key but not the webhook signing secret. These are two different secrets. Change the endpoint signing secret without updating
STRIPE_WEBHOOK_SECRETand every event returns 400, dropping fulfillment silently. Fix: rotate the pair together and send a test event before expiring anything. - Forgetting to redeploy. The value changes in the dashboard but "only apply to new deployments" [5], so the leaked key keeps working on the running deployment. Fix: deploy after the change and verify against the new deployment.
- Reusing a predictable replacement. Hand-typing a "random" replacement, or reusing a pattern, recreates the weakness. CWE-330 describes using "insufficiently random numbers or values in a security context," where "the resource being protected could be accessed by guessing the ID or key" [7]. Fix: generate the replacement with a cryptographically secure tool like the API key generator, not a keyboard mash.
- Hard cutover with no overlap window. Revoking before the new key is deployed and verified creates a guaranteed gap. Fix: use the overlap mechanism each provider gives you (Stripe's expiry timer, a second Resend or Supabase key) and only revoke after the new key serves traffic, unless the key is being actively exploited.
Make rotation a procedure, not a panic
The difference between a five-minute rotation and a half-day incident is whether the runbook exists before you need it. Write down the create-deploy-verify-revoke order, the provider-specific overlap mechanism for each key your app holds, and the redeploy-then-verify step. Keep it next to your incident notes, not in someone's head.
Validation at the boundary helps, but know its limit. SecureStartKit checks key prefixes at startup (sk_, whsec_, pk_, re_) with a Zod schema, which catches a fat-fingered paste during a rushed rotation. It does not catch a stale-but-valid key, because a wrong key with the right prefix passes the check. Prefix validation is a typo guard, not rotation verification; the only real verification is watching the new key serve traffic.
Rotation is the reactive half of the job. The proactive half is shrinking what a single leaked key can reach, so the next incident is smaller by design. Scope every key to its narrowest job: a Stripe restricted key instead of the full secret key for a service that only reads, a separate Resend key per application action, and the Supabase publishable key rather than the secret key anywhere the secret is not strictly required. A leaked key that can do one thing is a smaller fire than a leaked key that can do everything, and it is a faster rotation when it happens.
If you want the broader pre-incident checklist that this rotation runbook plugs into, the SaaS security checklist covers the secret-management items alongside the rest of the launch surface. And once you have migrated to the new Supabase keys and paired your Stripe secrets correctly, rotation stops being the thing you dread and becomes a routine you can run on a Tuesday afternoon without anyone noticing.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- JWT Signing Keys | Supabase Docs— supabase.com
- Rotating Anon, Service, and JWT Secrets | Supabase Docs— supabase.com
- Understanding API keys | Supabase Docs— supabase.com
- API keys | Stripe Documentation— docs.stripe.com
- Environment variables | Vercel— vercel.com
- API Keys | Resend Docs— resend.com
- CWE-330: Use of Insufficiently Random Values— cwe.mitre.org
Related Posts
The Secure SaaS Launch Checklist: 7 Non-Negotiables [2026]
Seven security checks every solo dev must verify before going live: auth, RLS, Zod, webhooks, headers, secrets, error handling. The pre-launch audit.
Supabase MFA Recovery: 5 Lost-Device Failure Modes [2026]
Supabase MFA recovery without recovery codes: backup TOTP factor, audited service_role reset, AAL-preserving rebinding, and 5 lost-device failure modes.
Supabase Storage Multi-Tenant RLS: 5 Leak Modes [2026]
Supabase Storage multi-tenant isolation: path-encoded RLS with tenant_id JWT claim, bucket-vs-path decision, and 5 cross-tenant leak modes.