← All articles

Webhook Signature Verification Failed: 6 Causes and Fixes

Webhooker Team Updated 13 min read
Flat illustration of failed signature verification: a delivery strand shattering into six splinters at a blue pulse node, continuing only as a dashed remnant.

No signatures found matching the expected signature for payload and its equivalents all mean one thing: the HMAC your code computed does not match the one the provider sent. Only four inputs go into that computation: the signing secret, the exact bytes of the raw body, the hash algorithm, and the encoding of the result. Six mistakes account for nearly every “webhook signature verification failed” error you will meet in production, and a body that was parsed before you hashed it is the most common by a wide margin.

The error string depends on the sender. That phrasing is Stripe’s; GitHub returns a mismatch against X-Hub-Signature-256; Shopify, Slack and Square each word it differently, and hand-written handlers often log a generic “invalid webhook signature”. The arithmetic underneath is identical, so this guide stays provider-agnostic.

Read the failure correctly

A signature mismatch is an equality check between two hex or base64 strings that came out different. Only four things can be wrong: the secret you hashed with, the body bytes you hashed, the algorithm (SHA-256 where the provider used SHA-1, or a signed payload whose timestamp and version prefix you left out), and the encoding of the digest you compared. Everything below is an instance of one of those four; for the mechanics underneath, we cover how HMAC webhook verification works end to end elsewhere.

What your error string actually means

Providers word the same arithmetic failure differently, and the wording narrows the search before you touch any code.

”No signatures found matching the expected signature for payload”

This is Stripe’s message, raised by constructEvent when none of the signatures in the Stripe-Signature header match the HMAC computed over the signed payload. It means the comparison ran and lost; a missing header raises a different error. Stripe signs the string timestamp + "." + raw body, so both a re-serialized body and a dropped timestamp prefix produce this message, and so does a secret from a different endpoint. Start at cause 1, then cause 2 and 6 below. If you need the exact raw-body call for Express, Next.js, Django, Flask, FastAPI or PHP, the Stripe verification guide has it per framework.

”Timestamp outside the tolerance zone”

Also Stripe, and a different problem entirely: the signature matched, but the signed timestamp is too far from your server’s clock. The default tolerance is 300 seconds. Rotating a secret here fixes nothing, so go to cause 4.

A X-Hub-Signature-256 mismatch

GitHub sends a lowercase hex digest prefixed with sha256=, and there is no server-side error string: your own code decides. Two specifics catch people out. The prefix must be stripped or included on both sides consistently, and GitHub redelivers, so a handler that already failed once will see the same payload again. We walk through GitHub’s SHA-256 header and redeliveries separately.

A X-Shopify-Hmac-Sha256 mismatch

Shopify’s digest is base64, not hex. Comparing it against a hex digest fails on every request and looks like a wrong secret. That, plus the mandatory GDPR topics, is covered in Shopify HMAC verification.

If your sender is not in this list, the classification still holds: every message above is a symptom of one of the six causes.

The six usual causes

  1. The body was parsed or re-serialized before you hashed it.
  2. The secret is wrong, rotated, or padded with invisible characters.
  3. The encoding does not match: hex against base64, or a leftover sha256= prefix.
  4. Clock skew pushed the request outside the timestamp tolerance.
  5. A proxy, CDN or WAF rewrote the request in transit.
  6. The secret belongs to a different endpoint or environment.

1. The body was parsed or re-serialized

This is the cause behind most tickets. The provider signed the exact bytes it put on the wire; your framework parsed those bytes into an object and handed you the object. Re-serialize it to hash and you get a different byte sequence: key order shifts, Unicode escapes resolve, whitespace disappears. The JSON means the same thing, but the HMAC over it is different.

The rule is the same in every stack: capture the raw body, verify, then parse. In Express, mount a raw parser on the webhook route only, ahead of any global express.json():

app.post('/webhooks', express.raw({ type: 'application/json' }), handler);
// req.body is now a Buffer — hash it, then JSON.parse it

In FastAPI, read the body yourself instead of binding a model:

raw = await request.body()   # bytes, exactly as received
payload = json.loads(raw)    # only after the HMAC matches

In Rails, read and rewind, because something downstream will want the stream too. In Laravel, getContent() returns the untouched bytes; $request->all() does not:

raw = request.body.read
request.body.rewind

The sign of this cause: the mismatch reproduces on every request and nothing about the payload is unusual. Wrong-secret failures look identical, so use the ladder at the end rather than guessing.

2. The secret is wrong, rotated, or padded

Providers usually issue one signing secret per endpoint rather than per account, so an endpoint you created last week does not verify with the secret you copied last year. Rotation does the same thing: the dashboard shows the new secret, your deployment still holds the old one. On Stripe specifically, the value lives behind Click to reveal in Workbench, and where to find your Stripe webhook signing secret covers the path and the rotation overlap.

Then there is whitespace. A secret pasted into a .env file with a trailing newline, or wrapped in quotes your loader keeps, hashes to something completely different. Log the length of the secret your process loaded (never the secret itself) and compare it to the dashboard value. A difference of one or two characters points straight at stray whitespace or quotes. A WEBHOOK_SECRET that falls back to an empty string is harder to spot: it computes a valid-looking HMAC keyed with nothing and never raises a configuration error.

3. Encoding mismatch

Providers do not agree on how to present the digest. Some send lowercase hex, some base64. GitHub prefixes its hex digest with sha256=; Shopify sends base64; Stripe packs a timestamp and one or more signatures into a comma-separated header. A few encode the secret as well as the digest, so the string from the dashboard has to be base64-decoded into bytes before it becomes the key. Sweego works that way, and using its secret as a plain string fails the same way a wrong secret does. Compare against the wrong representation and nothing will ever match.

Two habits prevent this: normalize both sides to bytes rather than comparing display strings, and use a constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node, hash_equals in PHP). Formats are provider-specific, so check the primary source: GitHub’s is in validating webhook deliveries.

4. Clock skew

Some schemes sign a timestamp alongside the payload and reject anything outside a tolerance window, commonly five minutes. When that window rejects a request, the signature itself was often valid, and the fix lives in the clock rather than in the crypto code. Several SDKs report both failures with the same message, so separate them in your logging first, or you will rotate a secret that was never wrong.

Skew shows up when a container has no NTP sync, when a VM resumes from suspension, or when a queue holds the request for minutes before verification runs. The symptom is intermittency: most requests verify, a burst fails, and a manual replay succeeds. If verification runs after a job queue, the queue itself creates the skew: verify at the edge, then enqueue. Stripe documents its tolerance in the guide to verifying webhook signatures, and we cover Stripe-specific verification and its failure modes and why a valid signature alone does not stop replays elsewhere.

5. A proxy or CDN rewrote the request

Anything between the sender and your handler can change the bytes. A reverse proxy may decompress a gzipped body, a load balancer may normalize charset or line endings, a WAF may strip something it considers suspicious, and a body-size limit may truncate a large payload before your code sees it. Behaviour varies by product and configuration, so treat this as a hypothesis to test before you change infrastructure.

The test is cheap: compare the byte length of the body you received to the Content-Length the sender set. If they differ, the payload changed in transit and no amount of code review will help. Large payloads failing while small ones pass points at a size limit.

6. The wrong endpoint or environment

This cause lasts a long time because both halves look correct in isolation. Test-mode secrets do not verify live-mode events, and a staging endpoint’s secret does not verify traffic a colleague pointed at production. Each endpoint you register with a provider has its own secret, and copying the wrong dashboard row is easy to do and hard to see. Check the delivery’s identity: most providers put an event or delivery ID in a header or the payload, so find that ID in the sender’s dashboard and confirm which endpoint it went to. Usually it was not the one whose secret you hold.

Table: how each cause tends to present, and the cheapest check that confirms or eliminates it.

SymptomLikely causeFastest check
Every request fails, payloads normalParsed body, or wrong secretHash a stored raw body offline
Digests differ in length or alphabetEncoding mismatchPrint both digests side by side
Most pass, bursts failClock skewSigned timestamp vs server time
Large payloads fail, small ones passProxy or body-size limitByte length vs Content-Length
Fails in production, passes locallyWrong environment or endpointMatch the delivery ID in the dashboard
Started failing at a known timeRotated secret, or a deploySecret length vs the dashboard value

A debugging ladder

Work these in order. Each rung eliminates a class of cause, so stop as soon as one explains the failure.

  1. Log the two digests, both normalized, plus the secret’s length but never its value. If they differ in length or alphabet, you have an encoding problem and can skip the rest.
  2. Log the raw body’s byte length and its own SHA-256, and compare against Content-Length. A difference proves the bytes changed before your handler, which moves the investigation to your infrastructure.
  3. Replay one known-good payload offline. Compute the HMAC over a captured raw body in a script with no framework in the loop. If it matches, the HMAC code is correct and the raw body is being altered somewhere in your request pipeline. To drive the same test from the outside, webhook-mock-sender sends a correctly signed Stripe, GitHub or Shopify event to your endpoint, and --invalid-signature --expect 400 proves the rejection path works too. Or point the provider at our online webhook tester and paste the secret: it verifies the signature over the exact bytes that arrived and shows the signed string and both digests, so you can see which input your code gets wrong.
  4. Reproduce in staging with a staging secret, sending the provider’s own test event to a copy of your service. This is the safe version of “turn it off and see”.
  5. Check the clock on the failing host specifically, not on your laptop.
  6. Confirm the endpoint identity by looking up the delivery ID in the sender’s dashboard.

Disabling verification is deliberately absent from this ladder. An unverified endpoint is a public, unauthenticated write path into your system, and anyone who learns the URL can post to it. A flag added to switch verification off for one incident tends to stay in the codebase after it. Every rung above yields the same information without that exposure.

Where the common cause disappears

Most of the causes above come from application frameworks, which parse, buffer and re-encode the raw body before your handler sees it. Gateway-side verification moves the check in front of the framework. On Webhooker, each source has its own ingest URL, and the gateway computes HMAC-SHA256 or SHA-1 over the bytes exactly as received, answering a failed check with a 401 before the payload reaches the delivery pipeline. The rejected request is still kept, marked invalid, so it shows up in your audit trail but is never delivered. Because no framework sits between the wire and the check, cause number one does not apply at that layer. Live tail and per-attempt history show what arrived without a debugging deploy. Verification at ingest is included on every plan, including the Free plan.

Frequently asked questions

What does “No signatures found matching the expected signature for payload” mean?

It is Stripe’s way of saying the HMAC it computed over the signed payload matched none of the signatures in the Stripe-Signature header. The header was present and parsed; the digests simply differed. Three causes produce it almost every time: the body was parsed and re-serialized before hashing, the endpoint secret belongs to a different endpoint or to test mode instead of live, or the timestamp prefix was left out of the signed string. Hash a captured raw body offline with the dashboard secret. If that matches, the problem is in your request pipeline rather than your HMAC code.

Why does it work locally but not in production?

Almost always because something in the production request path is absent locally. The two usual suspects are a proxy, CDN or WAF that alters the body in transit, and a different secret in production, often a live-mode secret where you tested with a test-mode one. On a failing production request, compare the received byte length against Content-Length, then the loaded secret’s length against the dashboard value. Clock skew on a host that never syncs NTP is the third candidate.

Can I temporarily disable verification to unblock a release?

No. An endpoint without verification is an unauthenticated write path that anyone who discovers the URL can post to, and forged events can create orders, credit accounts or trigger downstream jobs. The flag you add for one afternoon tends to survive for months. Instead, log the computed and received digests side by side, replay a captured raw body offline, and reproduce the failure in staging with a staging secret. That gives you the same information without accepting a single untrusted request.

How do I rotate a signing secret without downtime?

Accept both secrets during a transition window. Compute the HMAC with the new secret first, and if it does not match, fall back to the old one before rejecting the request; both comparisons should be constant-time. Roll the new secret out to every instance, switch the provider to it, then count how many verifications still succeed only on the old one. When that count reaches zero and stays there for longer than the provider’s maximum retry window, drop the old secret.