Why Stripe Webhook Replays Fail Signature Verification (And The Re-Signing Fix)
You built a dead-letter queue. Your Next.js app or AWS Lambda function crashed on a Stripe invoice event, your queue caught the failed webhook, and you spent 30 minutes fixing the database bug. You deploy the fix, click "Replay"... and your application immediately throws a 400 Bad Request:
Error: Webhook signature verification failed: Timestamp outside the tolerance zone (1800s > 300s)
Every developer who attempts to build a webhook retry queue eventually runs headfirst into this wall. Here is why it happens, why common workarounds introduce security vulnerabilities, and how to architect a solution that preserves both security and zero-loss replayability.
1. The 300-Second Signature Window
When Stripe dispatches a webhook, it computes an HMAC SHA-256 signature across the current Unix timestamp and the raw JSON payload:
v1 = HMAC-SHA256(secret, "${t}.${rawBody}")
In the official Stripe Node SDK (and Python, Ruby, Go, and PHP equivalents), stripe.webhooks.constructEvent() does two independent checks:
- Cryptographic authenticity: Does
v1match the HMAC of${t}.${rawBody}? - Replay protection: Is
Math.abs(Date.now() / 1000 - t) <= 300(5 minutes)?
2. Why The Common Workarounds Fail
Bad Fix 1: Disabling Signature Verification on Retries
Some teams bypass verification if a custom header like x-replayed: true is present. This is disastrous: anyone who discovers your webhook endpoint can forge fake customer.subscription.created events with x-replayed: true and grant themselves free subscriptions.
Bad Fix 2: Bumping the Tolerance Window
// Dangerous: Opens a 3-day replay vulnerability
stripe.webhooks.constructEvent(body, sig, secret, 86400 * 3);
Increasing the tolerance to 72 hours allows retries to pass, but completely guts replay protection across your entire payment pipeline, allowing attackers to replay stale invoices.
3. The Correct Architecture: Ingress Verify & Outbound Re-Signing
The only robust solution that allows your downstream application to keep default 300-second tolerance without altering your production codebase is Ingress Verification with Fresh Outbound Re-Signing:
- At Ingress (<10ms): HookArmor receives the webhook from Stripe. It uses your endpoint's signing secret to verify the original signature within the 300s tolerance window. It rejects forgeries upfront and acknowledges Stripe with an immediate
200 OK. - Durable Storage: The raw uncorrupted payload is written to a persistent Dead-Letter Queue (SQLite in WAL mode).
- At Forward / Replay: When HookArmor relays the event (whether 2ms later, on retry 1 hour later, or upon manual replay next week), it computes a fresh timestamp
t=nowand generates a mathematically validv1signature using your secret.
Here is what the outbound re-signing logic looks like under the hood:
const crypto = require('crypto');
function signStripeHeaders(rawBody, headers, secret) {
const freshTimestamp = Math.floor(Date.now() / 1000);
const freshSignature = crypto
.createHmac('sha256', secret)
.update(`${freshTimestamp}.${rawBody}`)
.digest('hex');
return {
...headers,
'stripe-signature': `t=${freshTimestamp},v1=${freshSignature}`
};
}
4. The Outcome: Zero Code Changes in Your App
Because the forwarded request arrives with a fresh timestamp and a genuine cryptographic HMAC, your backend handler remains completely stock:
// In your Next.js App Router route:
export async function POST(req) {
const body = await req.text();
const sig = req.headers.get('stripe-signature');
// Works perfectly on first attempt AND on replay 3 weeks later!
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
await handleBilling(event);
return NextResponse.json({ received: true });
}
Never Lose a Stripe Webhook Again
HookArmor handles ingress verification, fresh outbound re-signing, background retries, and 1-click dead-letter replay in a single lightweight binary.
View on GitHub (MIT Free)