← All articles

Webhook Signature Verification Failed: 6 Causes and Fixes

Webhooker Team 12 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 request body, the hash algorithm, and the encoding of the result. Six mistakes account for nearly every mismatch 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. The arithmetic underneath is identical, so this guide stays provider-agnostic.

Read the failure correctly

A signature mismatch is not a mysterious cryptography problem. It is an equality check between two hex or base64 strings, and it failed. 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 — not that the header was missing. Stripe signs the string timestamp + "." + raw body, so both a re-serialized body and a dropped timestamp prefix produce exactly this message, and so does a secret from a different endpoint. Start at cause 1, then cause 2 and 6 below.

”Timestamp outside the tolerance zone”

Also Stripe, and a different problem entirely: the signature was fine, the clock was not. The default tolerance is 300 seconds. Rotating a secret here fixes nothing — 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 every single time 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 is semantically identical and cryptographically 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 tell: the mismatch is perfectly reproducible 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, not one 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 — 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 explains everything in five seconds. A WEBHOOK_SECRET that falls back to an empty string is the silent variant: it computes a valid-looking HMAC of 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. 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 was often perfectly valid — you are looking at a clock problem wearing a cryptography costume. 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 skew is self-inflicted — 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, not an accusation.

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 one survives 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 — 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, your crypto is fine and your pipeline is not.
  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.

Notice what is not on this ladder: disabling verification. An unverified endpoint is a public, unauthenticated write path into your system, and anyone who learns the URL can post to it. There is no temporary version of switching it off, because the flag outlives the incident. Every rung above yields the same information without the exposure.

Where the common cause disappears

Most of this article exists because raw bytes are fragile inside application frameworks. That is an architectural property, not a coding mistake, and it is why we verify at the gateway instead. Webhooker gives each source its own ingest URL and computes HMAC-SHA256 or SHA-1 against the bytes exactly as received, rejecting failures before the payload is accepted or stored. No framework sits between the wire and the check, so cause number one cannot happen, and live tail plus per-attempt history show what arrived without a debugging deploy. If you would rather stop debugging HMAC by hand, you get verification handled at ingest, with the raw body preserved on every plan, including the free one.

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, not your crypto.

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 — the same information, without 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.