How to Rotate Your Stripe Webhook Secret Without Downtime
Most rotation guides say "change the secret in Stripe, change the env var, redeploy." That works — and drops 30 seconds to 5 minutes of webhook events into the void while the new secret propagates ahead of (or behind) your deploy. The dual-secret cutover pattern below moves zero events to /dev/null and takes 5 minutes end-to-end.
Why naive rotation drops events
The race window: between the moment Stripe starts signing with the new secret and the moment your endpoint starts accepting it, every event signed with the new secret 400s on signature verification. Stripe will retry those events, but if your retention windows are tight or you have a high-volume endpoint, you're looking at 30-300 dropped events that need manual replay later — assuming you notice.
Worse: if your CI/CD takes 90 seconds to ship and Stripe propagates the new secret in 5 seconds, your endpoint is rejecting valid signatures for ~85 seconds. Every retry burns a slot in Stripe's exponential-backoff queue.
When you need to rotate
- Secret leaked in a screenshot, log, or repo (run
git log -p | grep whsec_if unsure) - Employee with secret access offboarded
- Compliance audit requires periodic rotation (PCI, SOC 2)
- Vendor compromise (third-party CI tool, monitoring service, etc.) had access to your env vars
If none of these apply: don't rotate. Every rotation introduces risk of dropped events — only pay the cost when you have a reason.
The dual-secret cutover pattern
Stripe's dashboard supports two signing secrets per endpoint simultaneously. You add the new one alongside the old, deploy code that accepts either, then delete the old one once you've confirmed the new is working. Zero events at risk.
Step 1 — Add a second signing secret in the Stripe dashboard
- https://dashboard.stripe.com/webhooks → click your endpoint
- "Signing secret" section → "Roll secret" → choose "Keep both for X days" (Stripe accepts 24h, 7d, or custom)
- Copy the new
whsec_...value
Stripe is now signing every event with the new secret. The old secret continues to be valid for verification only (Stripe won't sign with it anymore). Your endpoint, still verifying with the old secret, will start 400ing immediately. Don't pause here — keep moving.
Step 2 — Deploy code that accepts either secret
// /api/stripe-webhook.js — accept old OR new secret during cutover
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const SECRETS = [
process.env.STRIPE_WEBHOOK_SECRET, // current (about to retire)
process.env.STRIPE_WEBHOOK_SECRET_NEW, // new (just added)
].filter(Boolean);
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
const sig = req.headers['stripe-signature'];
const rawBody = await getRawBody(req); // your raw-body helper
let event = null;
let lastError = null;
for (const secret of SECRETS) {
try {
event = stripe.webhooks.constructEvent(rawBody, sig, secret);
break; // verified — stop trying
} catch (e) {
lastError = e;
}
}
if (!event) {
console.warn('webhook signature verification failed', lastError?.message);
return res.status(400).send('Webhook Error: signature verify failed');
}
await processEvent(event);
return res.status(200).json({ received: true });
}
Set both env vars in Vercel/Render/Fly/etc. Deploy. The handler tries the old secret first; if it fails, falls back to the new. Once Stripe is fully on the new secret, every verification passes on the second attempt — slightly slower but functionally correct.
STRIPE_WEBHOOK_SECRET_NEW first if you've already deployed past the moment of rotation.
Step 3 — Verify both secrets work
Trigger a test event and confirm it processes. From the Stripe dashboard:
- Webhooks page → your endpoint → "Send test webhook"
- Pick any event type (e.g.
checkout.session.completed) - Send. Check your logs for
received: true.
Or via Stripe CLI:
stripe trigger checkout.session.completed --live
# Watch your endpoint logs. Should return 200.
Now manually flip the env var order in your runtime — put the OLD secret second. Trigger again. Should still pass. If yes: both secrets verify correctly. Proceed.
Step 4 — Wait for the cutover window to close
Stripe will continue accepting both secrets for the duration you chose in Step 1 (default 7 days). During that window, all events sign with the new secret and verify against either. There's no traffic on the old secret anymore — it's just there as a safety net.
Use this window to:
- Audit logs for any 400s during the deploy window — confirm zero drops
- Update your secrets manager / vault entry to the new value
- Notify the team that rotation is in flight (so nobody hard-codes the old one in a debug session)
Step 5 — Retire the old secret
After the cutover window expires, Stripe automatically deletes the old secret. You should:
- Remove
STRIPE_WEBHOOK_SECRETfrom your env - Rename
STRIPE_WEBHOOK_SECRET_NEWtoSTRIPE_WEBHOOK_SECRET - Update the SECRETS array to a single entry
- Deploy
This isn't urgent. The two-secret handler is harmless after the old secret is retired — both array entries verify against the same backend, so you just have a one-iteration loop. Clean up at your next routine deploy.
Edge cases
What if my endpoint is multi-tenant?
If you have one endpoint serving multiple Stripe accounts (e.g. a Connect platform), rotate one account's secret at a time. Tag each secret with the account ID and look it up by header:
const accountId = req.headers['stripe-account']; // present on Connect events
const secrets = SECRETS_BY_ACCOUNT[accountId] || SECRETS_BY_ACCOUNT.default;
If you don't pass through Connect, this header may not be set — fall back to the default array.
What if I have a queue between Stripe and my handler?
Verification still happens at the entry point, before the queue. Rotate the secret on whichever process is first to receive the raw body — typically your Vercel/edge function. Workers consuming from the queue don't need the secret because the event is already verified.
What if my secret got committed to git?
Rotate immediately (don't wait for the cutover window). Then:
- Run
git log --all -p -- '*' | grep -i whsec_to find every commit that touched it - Use BFG Repo Cleaner or
git filter-repoto scrub the secret from history - Force push (if the repo is yours) or rotate again after squashing if it isn't
- Audit Stripe dashboard → API logs for any signed requests from unexpected IPs during the leak window
Don't want to babysit secret rotation?
Sentry Forge ships your Stripe webhook with the dual-secret pattern baked in from day one — plus signature verification, idempotency, retry-aware logging, and Vercel deploy in 24 hours. $199 flat, first 3 clients.
See the offer →Verification checklist
- ☐ New secret added in Stripe dashboard (cutover window set, e.g. 7 days)
- ☐ Both secrets present as env vars in production
- ☐ Code accepts either secret (multi-secret loop deployed)
- ☐ Test event processed — verify in logs
- ☐ Reverse env var order, test again — verify in logs
- ☐ Production traffic monitored for 400s during cutover (expect zero)
- ☐ Old secret retired post-cutover, env cleaned up, code redeployed