DEV Community

Cover image for Stop guessing why your webhook signature check fails
Webhooker
Webhooker

Posted on Originally published at webhooker.eu

Stop guessing why your webhook signature check fails

No signatures found matching the expected signature for payload.

That is Stripe's wording. GitHub gives you a mismatch on X-Hub-Signature-256, Shopify words it differently again, and hand written handlers usually log something like "invalid signature". They all mean exactly one thing: the HMAC you computed is not the HMAC that arrived.

What none of them tell you is which input was wrong. And that is the whole problem, because there are only four:

  1. the signing secret
  2. the exact bytes of the request body
  3. the hash algorithm
  4. the encoding of the result

Every failed verification is one of those four being different from what the sender used. Four candidates is a small enough space that you should never be guessing. Yet the usual debugging session is an hour of swapping secrets and re-reading docs, because the error message collapses four distinct failures into one string.

Here is how to find the broken input in about five minutes.

First, log the two things that actually narrow it down

Before changing any code, add this to the handler and send one delivery through it:

const raw = await getRawBody(req);   // whatever your framework gives you

console.log({
  bytes: raw.length,
  contentLength: req.headers["content-length"],
  sha256: crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16),
  header: req.headers["stripe-signature"],  // or x-hub-signature-256, etc
});
Enter fullscreen mode Exit fullscreen mode

Never log the raw body itself. It has customer data in it, and now your logging provider has it too. The hash and the length tell you what you need without keeping the payload around.

Two numbers, one comparison, and you have already split the problem in half.

Check one: does the byte count match?

Compare bytes against the content-length header the sender set.

Different? Your body was modified before you hashed it, and nothing else matters until you fix that. This is the single most common cause by a wide margin, and every vendor doc that ranks for this error says so.

The usual culprit is a JSON parser that ran first. express.json(), body-parser, FastAPI's automatic model binding, a framework middleware you never registered explicitly. The parse gives you an object, you re-serialise it to hash it, and JSON.stringify does not reproduce the sender's bytes. Key order can shift. Whitespace is gone. Unicode escaping differs. One byte off and the HMAC is completely different, which is the entire point of a hash.

In Express the fix is to keep the raw buffer on the webhook route only:

app.post("/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    // req.body is a Buffer here, not an object
  }
);
Enter fullscreen mode Exit fullscreen mode

Order matters. If app.use(express.json()) is registered above this line, it wins and you are back to a parsed body.

Same number? Good, the body is intact. The problem is one of the other three, and you can stop rewriting your middleware stack.

Check two: is it the secret or the encoding?

Now sign a known value and compare against a known answer, taking your app out of the picture entirely:

const mac = crypto.createHmac("sha256", "test_secret")
  .update("hello")
  .digest("hex");

// 8e3c5f1f7dcbf1e6e9a0d0f2b1e3c... whatever your runtime gives
console.log(mac);
Enter fullscreen mode Exit fullscreen mode

Run the same thing in a plain Node REPL, or openssl dgst -sha256 -hmac test_secret. If the two differ, the bug is in how your code builds the HMAC, not in the webhook at all.

Two specific things to look at here.

Encoding. Stripe sends hex. Shopify sends base64. If you compare a hex digest against a base64 header, the lengths alone will never match, and the failure looks identical to a wrong secret. Check the length of what you computed against the length of what arrived. 64 characters is a hex sha256; 44 characters ending in = is base64.

The secret itself. Copying from a dashboard drags in a trailing newline or space more often than anyone admits, and the value is invisible in logs. Print secret.length once. Stripe signing secrets start with whsec_ and the whole string is the secret, prefix included. Do not strip it. And the signing secret is not the API key: sk_live_ will never verify anything, no matter how correct the rest of the code is.

Check three: are you on an edge runtime?

This one is missing from almost every tutorial, because the tutorials predate the platforms.

Cloudflare Workers, Deno Deploy, Vercel Edge, Supabase Edge Functions. None of them have Node's synchronous crypto. Stripe's constructEvent is synchronous and will throw or silently misbehave there. The async variant exists precisely for this:

const event = await stripe.webhooks.constructEventAsync(
  rawBody, signature, secret
);
Enter fullscreen mode Exit fullscreen mode

That single change is what fixes a surprising number of "it worked on Pages, it broke on Workers" threads. The Cloudflare community has a good example from January where a working webhook broke on migration for exactly this reason.

The second edge trap is that a Request body can only be read once. If anything upstream already called await req.json(), your await req.text() returns an empty string, you hash nothing, and the signature never matches. Read the text first, parse from the string you already have:

const raw = await req.text();       // read once
const signature = req.headers.get("stripe-signature");
const event = await stripe.webhooks.constructEventAsync(raw, signature, secret);
const data = JSON.parse(raw);       // parse from the same string, not the request
Enter fullscreen mode Exit fullscreen mode

If you are writing the HMAC yourself on an edge runtime, it is Web Crypto, and all of it is async:

const key = await crypto.subtle.importKey(
  "raw", new TextEncoder().encode(secret),
  { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(raw));
const hex = [...new Uint8Array(mac)].map(b => b.toString(16).padStart(2, "0")).join("");
Enter fullscreen mode Exit fullscreen mode

"It works locally but not in production"

If local is fine and production fails, the four inputs are the same in your code, so something in between changed the request or the environment.

A proxy or CDN that buffers and re-emits the body will change the bytes. So will decompressing a gzipped payload and re-encoding it. Nginx with proxy_set_body, an API gateway doing request transformation, a WAF that normalises JSON. Any of these produce the same symptom as a parser.

Or it is simply the wrong secret for the environment. Test and live endpoints have different signing secrets, and a .env that got copied between them fails in a way that looks mysterious but is not.

One more that catches people: a tunnel used in local development. Some tunnels rewrite headers or re-encode the body, which means local passes for the wrong reason and production is where you first meet real bytes.

While you are in there, fix the comparison

Once verification passes, check how you compare the two values. This is not why it fails, but it is where a fixed handler often stays broken in a way nobody notices:

if (computed === received) { }                 // leaks timing information
if (crypto.timingSafeEqual(a, b)) { }          // constant time
Enter fullscreen mode Exit fullscreen mode

timingSafeEqual throws if the two buffers have different lengths, so guard for that first. And check the timestamp too. A valid signature on a request captured an hour ago is still a valid signature, which is why providers include a timestamp in the signed payload and expect you to reject anything outside a few minutes.

The thing worth keeping

Leave the length and hash logging in permanently. Not the body, just bytes, the truncated hash, and the header. It costs nothing, contains no customer data, and the next time this breaks you will know within one delivery whether the bytes changed or the secret did.

That is really the whole trick. The error message describes a symptom shared by four different causes, so stop reading the message and start measuring the inputs.

Longer write up with the full list of causes, the error strings each provider uses, and what to do about clock skew: Webhook signature verification failed: 6 causes and fixes.

Top comments (0)