Skip to main content

Stripe

Stripe webhook replay: a valid signature is not a fresh event

Your handler verifies v1= and ships. It never checks t=. A replayed event with a valid signature and a stale timestamp fulfils the order twice.

FetchSandbox EngineeringFetchSandbox Engineering

Someone captures one payment_intent.succeeded request off your webhook endpoint. An hour later they POST the exact same bytes back, with the exact same Stripe-Signature header.

Your handler verifies the signature. The signature is valid, because it always was. The order gets fulfilled a second time.

That is not a signature bug. Your verification code is correct. It is just answering a narrower question than you think it is.

t= is not decoration

A Stripe-Signature header has two parts:

Stripe-Signature: t=1755561600,v1=5257a869e7ecebeda32affa62cdca3fa...

v1= is an HMAC over t concatenated with the raw body. t= is when Stripe signed it. Verifying v1= proves the payload came from Stripe and was not modified. It proves nothing about when.

Stripe's official libraries compare t= against the current clock and reject anything outside a 300-second tolerance. That check is the only thing standing between you and a replay. It is also the first thing to disappear when someone hand-rolls verification, because the signature comparison is the part that feels like the security-relevant bit:

// Verifies authenticity. Does not verify freshness.
const [t, v1] = parseSigHeader(req.headers['stripe-signature']);
const expected = hmacSha256(`${t}.${req.body}`, secret);

if (!timingSafeEqual(expected, v1)) return res.sendStatus(400);
// t is parsed, used in the HMAC, and then never compared to Date.now()

This passes every test you would think to write. The signature is real. The body is untampered. The event is hours old.

Why this is a different bug than the two you already fixed

Three failure modes get filed under "Stripe webhook signatures" and they fail in different places:

BugWhat breaksYour verification code is
Raw body parsed before verifyexpress.json() re-serialises, HMAC no longer matchesCorrect, fed bad input
Duplicate deliveryStripe's own retry arrives twice, no dedupe on event.idCorrect, called twice
Stale signed replayAttacker resends a valid old request, no t= checkCorrect, and insufficient

Idempotency on event.id is the closest defence, and it is why this one hides so well. If you dedupe, a replay of an event you have already processed is a no-op and you feel safe. But dedupe usually has a retention window. Replay something older than your dedupe table remembers and the side effect runs again. Idempotency and freshness are two controls, not one.

The invariant

An event that carries a valid signature and a timestamp outside tolerance must be rejected before it reaches business logic. Not deduped. Not processed and ignored. Rejected, with a 400, at the boundary.

valid signature, t= 30s old    → 2xx, fulfilment runs once
valid signature, t= 600s old   → 400, fulfilment does not run

The second line is the whole test, and it is the line that never gets written, because producing a validly-signed-but-stale event by hand means either waiting ten minutes with a captured request or writing your own HMAC signer with a backdated clock.

stripe trigger will not do it. It signs with the current time, which is the one case you already handle.

Force a stale signed event

FetchSandbox ships this as a scenario on the Stripe spec. replayed_old_signed_event signs events Stripe-style and backdates t= by 600 seconds, twice the default tolerance:

replayed_old_signed_event:
  webhook_signed: true
  webhook_replay_old_timestamp_secs: 600

Run it against the accept_payment workflow with the FetchSandbox MCP server in Cursor or Claude:

./fetchsandbox quickrun stripe accept_payment
  --scenario replayed_old_signed_event

Assert my endpoint returns 400 on the stale delivery and that
fulfilment ran exactly once, keyed on the PaymentIntent id.

Or from the CLI, which is the same run in a shape CI can gate on:

fetchsandbox run <sandbox-id> accept_payment \
  --scenario replayed_old_signed_event --json

Both produce a receipt at fetchsandbox.com/runs/<sandbox-id>?flow=<run-id> showing the signed delivery, the timestamp it carried, your handler's status code, and the resulting state. The receipt is the part that matters: a handler with no t= check returns 200 on a ten-minute-old event, and you can see it did.

Worth knowing that a passing HTTP status is not the same as a passing run. Scenario runs carry a verdict with proven: true|false, so "the request succeeded" and "the behaviour was correct" stay separate. A 200 here is the failure.

The rule

Verify the signature, then verify the clock, then dedupe on event.id. Three checks, three different attacks. If you only test with freshly signed events, the tolerance branch has never executed, and the first thing to run it will be someone replaying your traffic.

Related: webhook sandbox · Clerk webhook replay and idempotency · test Stripe webhooks without production keys