Stripe Webhook Signature Verification in Production
Protecting your webhook endpoint from forged events requires more than just signature checks.
A Stripe webhook endpoint sits outside the normal login system, and its only authentication is the cryptographic signature carried in the Stripe-Signature header. No session cookie protects it, and no user token checks who is knocking. The URL itself is easy to find: it appears in CI logs, in browser developer tools, in old Git commits, and in container images that were never meant to leave the build pipeline. Once someone has that URL, they can send it a POST request, and the signature check is the only thing standing between that request and the systems behind it: fulfillment, accounting, access control. Four attack classes test that gate directly. Signature verification closes the first three of these if you add a narrow timestamp window. Secret rotation and least-privilege storage close the fourth. The consequences of skipping this are concrete: unauthorized entitlement grants, refund fraud, inflated revenue numbers, and regulatory exposure if the personal data riding inside the event payload gets mishandled.
How Stripe's signing scheme works
Stripe's signing scheme is deterministic, so you need to understand it precisely before you look at how it fails. Stripe computes an HMAC-SHA256 over the string timestamp.raw_body and sends the timestamp alongside one or more signatures in the Stripe-Signature header, formatted as a comma-separated list of key-value pairs. The string that actually gets signed is the timestamp, a literal dot, and the exact raw bytes of the request body, not a parsed object and not anything that's been re-serialized along the way. That scoping is simple on paper. It's also the root of one of the two most common verification failures in production, covered below.
Body mutation: the most pervasive cause of verification failures
The single most common implementation error is letting a JSON parser touch the request body before the signature verifier does. Re-stringifying a parsed JSON object can shift whitespace, reorder keys, or re-encode Unicode escapes, and any one of those changes alters the byte sequence enough to break the HMAC check. The fix is procedural: capture the raw body first, and hand those exact bytes to the verifier before any parsing logic runs. Every framework has its own version of this trap. In Express, the global express.json() middleware parses the body into a JavaScript object and leaves req.body holding a value that no longer matches the signed bytes, so express.raw({ type: 'application/json' }) needs to be mounted specifically on the webhook route, since applying it globally would parse every other route's body too. In the Next.js App Router, await req.text() returns the raw string, but only if you call it before anything else touches the body. CI tests pass cleanly while this bug lurks in a codebase because the test harness calls the handler function directly with a pre-built event object, bypassing the HTTP layer. It stays invisible until the first real delivery from Stripe arrives and fails.
Secret-mode confusion in production
Using a secret from the wrong mode produces the exact same error as body mutation: "No signatures found matching the expected signature for payload." The code looks correct, but the bug sits entirely in configuration, so it is hard to diagnose. Any Stripe integration has three independent secret types in play: the Dashboard signing secret for a live-mode endpoint, prefixed whsec_live…; the Dashboard signing secret for a test-mode endpoint, prefixed whsec_test…; and the short-lived secret the Stripe CLI prints out when stripe listen starts. These get crossed in predictable ways. Rotation, by contrast, doesn't have to be risky: deploying a new secret, confirming verification succeeds, and letting the old one expire works cleanly because both secrets stay active during the overlap window. The practical fix for the diagnosis problem is logging: recording which secret source (live, test, or CLI) a handler expected alongside every verification failure turns this entire class of bug from a mystery into something self-evident in the logs.
Tolerance windows
A replay attack is stopped by the timestamp in the Stripe-Signature header, but only if the receiving server enforces a tolerance window around it. Stripe's SDKs default to a 300-second (5-minute) tolerance, rejecting anything older. An HMAC signature says nothing about freshness, only about authenticity. If there is no timestamp check, an attacker who captures one validly signed event can replay it indefinitely, and the signature will verify every time. The tolerance window is what turns a static signature into a time-bound one, and it's a security parameter in its own right, not just a configuration detail to tune for convenience. That said, it cuts both ways. Setting it too tight makes legitimate deliveries start failing: slow CI runners, high-latency regions, or ordinary clock skew between a receiving host and Stripe's infrastructure can all push a delivery past the window through no fault of the sender. What does compound is confusion when a rejection happens silently. Every timestamp rejection should log the t value from the header and the delta between it and the current time, because without that detail, a stale-timestamp failure looks identical to any other verification error.
Idempotency: the failure mode that verification alone cannot prevent
Verification and idempotency do two different jobs: one proves an event is authentic, the other makes sure that authenticity doesn't get acted on twice. Stripe guarantees only at-least-once delivery, so a validly signed event can arrive more than once, and a handler with no idempotency check will process each arrival as if it were new. Stripe's retry schedule starts immediately on failure, moves to roughly 5 minutes, then roughly 30 minutes, then backs off exponentially across a window of up to 3 days, and the same event can show up across many of those attempts. For a handler with no deduplication, that produces duplicate welcome emails, entitlements granted twice, ledger entries recorded twice, fulfillment triggered twice. The standard defense is an idempotency store keyed on event.id: check whether the ID has been seen before inserting it, and back that check with a unique constraint so two simultaneous deliveries of the same event can't both slip through a race condition. Storing the processing result and any error alongside the event ID turns that table into something more useful than a lock: an audit log a team can actually read later. That check belongs inside the handler that runs after verification, never at the route level, so the idempotency store only ever sees events that have already been authenticated. And the handler should return a 200 response before processing finishes rather than making Stripe wait: a slow handler that times out reads as a failure to Stripe, which triggers a retry, which just adds another duplicate to the pile the idempotency store now has to catch.
What verification confirms: authorization is still your job
A verified signature proves one thing: the event came from Stripe's infrastructure. It says nothing about whether that event applies to the correct tenant, account, or customer inside an application's own data model, and treating verification as a stand-in for authorization is where a lot of otherwise careful handlers go wrong. Consider a Connect integration: a verified payment_intent.succeeded event could legitimately belong to any connected account on the platform, so the handler still needs to check the account field against the specific account it expects before acting on it. Entitlements should never be granted on the strength of event.type alone. The safer pattern is retrieving the relevant object, a subscription, a payment intent, an invoice, directly from Stripe to confirm its current state, since the event payload reflects conditions at the moment of delivery and not necessarily at the moment the handler gets around to processing it. And because that payload can carry personal information, it needs the same access controls and retention policy as any other sensitive data in the system, not the treatment of a routine log line.
Why the standard local testing setup hides these failures
The most common local testing pattern calls the handler function directly with a mocked Stripe.Event object, which tests business logic but skips the HTTP layer. That means it cannot catch body-mutation bugs, wrong-secret errors, stale-timestamp rejections, or missing idempotency guards, because none of those failure modes live in the business logic being tested. A direct function call never constructs a Stripe-Signature header, so secret-mode confusion and timestamp staleness have nothing to attach to, and the test has no way to notice they exist. A CI pipeline that mocks a call to POST /v1/payment_intents and checks for a 200 response has proven only that a request was accepted. Mocks are good at proving shape and bad at proving lifecycle: a stateless mock that returns the same order on every call to GET /orders/42 has no way to express the sequence that actually matters in production, which is create, fetch, update status, fetch again, and confirm the change stuck. The Stripe CLI's stripe listen command operates at a different level of fidelity. Running it in CI has one sharp edge: starting stripe listen as a background process needs a startup delay before the first trigger command fires, or the tunnel isn't ready yet and the event never arrives. And the secret it prints at startup has to be used as the endpoint secret for that session specifically, not whatever secret happens to be sitting in the Dashboard.
A testing architecture that catches all four failure modes before production
Catching body mutation, wrong secrets, stale timestamps, and missing idempotency all at once calls for a layered testing setup that exercises the full HTTP path, going beyond the handler function alone. The first layer is unit tests on handler logic: calling handler functions directly with fixture events is fast and well suited to checking business rules, but it stays blind to the HTTP layer, and that's fine only if the team building it understands what it isn't covering. The second layer is integration testing with real HTTP requests and real signature construction. That means you build a valid Stripe-Signature header from a fixture secret, post the raw body to a local server, and check both that the response is 200 and that the database reflects the expected change. It also means asserting that a tampered body returns 400, that a stale timestamp returns 400, and that a duplicate event ID returns 200 without triggering reprocessing, turning all four failure modes into concrete, passing assertions. Capturing a real sandbox event on an inspector endpoint, verifying its signature against a stored secret, and replaying it re-signed with a fresh timestamp exercises that whole path against a genuinely Stripe-shaped payload, a hand-built fixture cannot offer the same coverage. The third layer handles what neither of the first two can: the sequential lifecycle. Creating a PaymentIntent, receiving payment_intent.created, confirming it, receiving payment_intent.succeeded, and checking that the handler's internal state transitions correctly at each step requires something that remembers what happened before. A stateful Stripe simulator, one that persists state across calls, fires webhook events when that state changes, and matches the real API's wire format, can express that full lifecycle and catch the specific class of bug where a handler stores the wrong field or reads an undefined value on the second step of a sequence. That statefulness is also what makes a simulator a solid foundation for CI: it runs locally or in a container, needs no live credentials, hits no rate limits, and produces the same result every time, which is exactly the reliability that layers two and three depend on. Fault injection belongs at this third layer too, because a handler that verifies signatures correctly and deduplicates correctly can still misbehave under real network conditions. Test 429 responses simulating Stripe's rate limits, 503s simulating an outage, artificially slow responses to check timeout handling, and signature-tampered deliveries to confirm the 400 path holds up under load. How a handler responds to a 500 from its own database decides whether Stripe's retries end up working as intended or spiral into a loop that never resolves, and that path deserves a test of its own.
The production security checklist: six checks every webhook handler must run
A production-grade Stripe webhook handler runs six checks, and the order matters because each one depends on the one before it: skipping or reordering any single check leaves a specific attack class or failure mode wide open. The first check is simple and non-negotiable: the registered endpoint has to be HTTPS, since HTTP has no place receiving live-mode deliveries. The second is reading the raw body before any parser gets near it, using whatever the framework calls for, express.raw(), req.text(), request.get_data(), await request.body(), applied before a JSON parser ever touches the request. The third is verifying the v1 signature with the official SDK and confirming that the secret in the environment actually matches the mode of the endpoint it's running against, whether that's test, live, or a CLI session. The fourth is rejecting stale timestamps, enforcing a tolerance window of no more than 5 minutes and tightening that down to 60 to 90 seconds for endpoints handling high-value events, with the delta logged on every rejection. The fifth is looking up the event ID in an idempotency store and skipping processing if it has already been seen, and a unique constraint backs that check so two concurrent deliveries cannot slip through at once. The sixth is returning a 200 response immediately and processing the event asynchronously afterward, since a handler that times out looks like a failure to Stripe, which will keep retrying on any non-2xx response for up to 72 hours, and slow processing needs to happen after the response has already gone out. One habit ties all six together: every verification failure should produce a structured log entry carrying the source IP, the t value from the header, and the specific reason for the rejection, whether that's a stale timestamp, no matching signature, or a malformed header. Without that logging, you can barely diagnose a production incident involving any of these six checks after the fact.