Skip to main content

Why does webhook signature verification fail?

Webhook signature verification fails for one of four reasons, and almost never because the provider sent a bad signature. The receiver hashed different bytes than the sender signed, usually a JSON body that was parsed and re-serialized. It used the wrong secret. It encoded or compared the digest differently, hex where the provider used base64. Or the request's timestamp fell outside the tolerance window.

Receiving webhooks?
Svix Ingest is a reliable webhook receiver. It verifies signatures, absorbs delivery spikes, and gives you one endpoint for webhooks from any provider. Start receiving webhooks with Svix Ingest.

Check the bytes before the code

A webhook signature is an HMAC over a specific string, with a specific key, in a specific encoding. Change any of the three and the digests differ, and every library reports the same unhelpful result: no match. So debugging means checking each input in turn, most common first.

The body changed before you hashed it

This is the cause in most cases. Providers sign the raw request body, byte for byte. Most web frameworks parse JSON before your handler runs, and if you then call JSON.stringify or json.dumps on the parsed object to compute the signature, you are hashing a different string: key order, whitespace, unicode escaping, and number formatting can all change in the round trip.

The fix is to verify against the raw bytes. In Express, mount express.raw({ type: 'application/json' }) on the webhook route instead of express.json(), or use the JSON parser's verify callback to stash req.rawBody before parsing. In Next.js API routes, disable the body parser with bodyParser: false and read the stream yourself. Behind AWS API Gateway, check event.isBase64Encoded and decode the body before hashing, because binary-mode gateways base64-encode it in transit. A stripped trailing newline or a proxy that re-encodes the charset produces the same symptom. The webhook payload entry covers why verifying before parsing matters.

You are using the wrong secret

Every webhook secret is scoped, and the scopes are narrower than people expect. Stripe issues one secret per endpoint, and test mode and live mode have separate endpoints, so a handler verifying live traffic with a test-mode secret fails on every request. The Stripe CLI is a common trap: stripe listen prints its own whsec_ secret for the forwarding session, which is not the secret shown for your endpoint in the dashboard. Code that worked locally fails the moment real deliveries arrive because the environment variable still holds the CLI's value.

The whsec_ prefix hides a second difference. Stripe uses the whole string, prefix included, as the HMAC key. Providers following the Standard Webhooks specification, Svix among them, treat what follows the prefix as base64 and expect you to decode it into raw key bytes before hashing. Feed a Standard Webhooks secret to a Stripe-style verifier, or the reverse, and nothing matches.

Secret rotation is the last variant. During a rotation window, spec-following providers send several signatures in one header, space-separated, each prefixed with a version tag such as v1,. A verifier that only reads the first one starts failing partway through the window. Compare against every candidate and accept if any matches.

The digest is encoded or compared differently

Providers disagree on how to write the digest down. GitHub sends X-Hub-Signature-256: sha256=<hex>. Stripe sends Stripe-Signature: t=<timestamp>,v1=<hex>. Shopify sends X-Shopify-Hmac-SHA256 as base64. Standard Webhooks sends webhook-signature: v1,<base64>. Computing the right HMAC and then hex-encoding a digest the provider base64-encoded gives you two strings that will never be equal, and forgetting to strip the sha256= or v1, prefix before comparing does the same.

The comparison itself can fail loudly. Node's crypto.timingSafeEqual throws a RangeError when the two buffers differ in length, and a hex SHA-256 digest is 64 characters while the base64 form is 44. If your logs show that exception rather than a clean rejection, the encoding is wrong, not the key. Keep the constant-time comparison and fix the encoding so both sides have the same length.

The signed string matters as much as the encoding. GitHub and Shopify sign the body alone. Stripe signs t.body and Standard Webhooks signs id.timestamp.body, both joined with literal dots. Slack signs v0:timestamp:body with colons. Concatenate in the wrong order or with the wrong separator and the digest is wrong even though the body, key, and encoding are all right.

The timestamp is outside the tolerance window

Providers that sign a timestamp also enforce a timestamp tolerance, commonly 300 seconds, as protection against replay attacks. Two things trip it. A server whose clock has drifted rejects live traffic; the fix is NTP, not a wider window. And replaying a request captured yesterday to reproduce a bug fails verification by design, because that is exactly what a replay looks like. Sign a fresh request instead; our Postman guide shows how.

How to find which one it is

Log the length and a SHA-256 of the exact bytes you hashed, alongside the signature header you received and the digest you computed. If the length differs from the Content-Length header, the body changed. If your digest is a different length from the provider's, the encoding is wrong. If both lengths match and the values differ, it is the key or the signed string. Point the provider at a capture URL such as Svix Play to see the untouched bytes and headers, and compare them with what your framework hands you.

Two habits prevent most of this. Use the provider's official library, which knows the signed string, the key handling, and the encoding, and read the secret from one place per environment. Or hand the problem off: Svix Ingest verifies signatures from Stripe, GitHub, Shopify, Slack, and other major providers before events reach your code, so a wrong secret shows up in one dashboard instead of as a wall of 401s.

Frequently asked questions

Why does Stripe say "No signatures found matching the expected signature for payload"?

The Stripe library computed a different digest from the one in the Stripe-Signature header. Nine times out of ten you passed a parsed and re-serialized body instead of the raw bytes, which is why the message asks whether you are passing the raw request body. The other common cause is the wrong secret: a test-mode secret on live traffic, or the whsec_ value printed by the Stripe CLI instead of the dashboard endpoint secret.

Why does verification pass in Postman but fail in production?

Postman sends exactly the body you typed, so the pre-request script and your handler hash the same bytes. In production, a JSON middleware parses the body before your handler sees it, and re-serializing it changes the bytes. Verify against the raw body in production, and check that the production environment holds the production secret rather than a test or CLI one.

Should I disable signature verification if it keeps failing?

No. An endpoint that skips verification accepts forged events from anyone who finds the URL. Return a bare 401 while you debug, capture the raw request with a tool such as Svix Play, and compare the bytes, the secret, and the encoding one at a time. Every cause on this page is a configuration fix, not a reason to remove the check.

Does a burst of signature failures mean I am under attack?

Almost never. Forged requests trickle in; a sudden wall of failures on one endpoint means the two sides no longer agree on the secret or the body. The classic case is a secret rotation where the new value was installed on one side only. Check the secret in your deployed configuration against the one the provider shows before assuming anything worse.

Need to receive webhooks reliably?

Svix Ingest gives you one endpoint for webhooks from any provider, with signature verification and durability built in, so a redeploy or a traffic spike never drops an event.