</>CodeWithKarani

Webhook Signature Verification Fails Randomly? It Is Your Body Parser Order

Karani GeoffreyKarani Geoffrey8 min read

Your webhook signature check passes in testing, passes for the first few real events, and then one day a delivery fails verification. You retry it, it fails again, you check the next event and it passes fine. It looks random. So you do the thing the dashboard suggests: you rotate the signing secret, update it everywhere, and move on. A week later it happens again.

It is not random and it is not the secret. The signature is being computed over the wrong bytes. Somewhere before your verification runs, a body-parsing middleware, usually express.json(), has already parsed the raw request body into an object and, when you re-serialize it to check the signature, you get JSON that is semantically the same but byte-for-byte different from what the provider signed. Some payloads happen to round-trip identically and verify fine. Others do not, and those are your "random" failures.

Rotating the secret cannot fix this because the secret was never wrong. The bug is in the order your middleware runs, and the fix is to verify the signature against the exact raw bytes the provider sent, before anything parses them. If you have not yet added signature verification at all, start with why your webhook endpoint trusts anyone who can curl it; this article is about verification you already have that fails intermittently.

Providers sign the exact raw bytes of the request body. If a global body parser like express.json() runs first, your code re-serializes the parsed object to check the signature, and JSON.stringify(JSON.parse(body)) does not reproduce the original bytes for every payload, so verification fails on some events and passes on others. The fix: capture the raw body and verify the HMAC against those exact bytes before any JSON parsing touches the route. Scope express.raw() to the webhook route, or preserve the raw buffer with a verify callback. Rotating the secret does nothing.

The failure that looks random but is not

What you see in the logs is a signature mismatch on some deliveries and success on others, with the same secret, same endpoint, same code. Depending on the provider the failure surfaces as a thrown verification error or a comparison that returns false:

Error: Webhook signature verification failed

The clue everyone misses: it correlates with the content of the payload, not with time or load. An event with a Unicode character in a customer name fails; an event with only ASCII passes. An event where the provider happened to emit keys in a different order fails; another passes. It looks like flakiness because you are looking at timestamps. Sort your failures by payload shape instead and the pattern appears.

Why re-serialized JSON does not match

A webhook signature is an HMAC computed over the raw request body bytes, using your shared secret as the key. The provider does this: take the exact bytes they are about to send, HMAC them, put the result in a header like Stripe-Signature, X-Hub-Signature-256, or X-Shopify-Hmac-Sha256. Your job is to HMAC the exact same bytes and check they match. Byte-for-byte. The HMAC of "almost the same JSON" is not "almost the same hash"; it is a completely different hash. There is no partial credit in a hash function.

Now watch what a global JSON parser does to that. express.json() reads the raw body, parses it into a JavaScript object, and throws the raw bytes away, leaving you req.body as a parsed object. When your verification code needs "the body" to hash, the raw bytes are gone, so you reach for JSON.stringify(req.body). That round-trip does not reproduce the original for many real payloads:

  • Whitespace. The provider may send compact JSON or pretty-printed JSON with spaces and newlines. JSON.stringify produces its own spacing, which differs.
  • Key order. JSON objects have no canonical key order. If the provider serialized keys in one order and your engine re-serializes in another, the bytes differ even though the object is "equal".
  • Unicode escaping. A non-ASCII character may be sent as a raw UTF-8 byte or as a \uXXXX escape. The two are semantically identical and byte-wise different.
  • Number formatting. Trailing zeros, exponent notation, and integer-versus-float representation can change on a parse-and-restringify.

Any one of these flips one byte, which flips the entire hash, which fails verification. Payloads that happen to avoid all of them round-trip cleanly and pass, which is exactly why it looks intermittent.

Broken: parse first, re-serialize to verify raw bytes express.json() raw bytes discarded JSON.stringify(body) different bytes HMAC differs fails on some Correct: verify raw bytes, then parse raw bytes HMAC(raw) vs header exact bytes matches then JSON.parse for your handler The only reliable input to the HMAC is the untouched request body. Parse after you verify, never before.
Verification has to see the same bytes the provider signed. Once a parser has round-tripped the body, those bytes no longer exist.

The fix, in steps

Step 1: Stop the global parser from touching the webhook route

The root cause is ordering: a global app.use(express.json()) runs before your route handler, so by the time you verify, the raw body is gone. You need the webhook route to receive the raw body while the rest of your app still gets parsed JSON. Scope a raw parser to that one route and register it before the global JSON parser cannot reach it:

// webhook route gets the RAW body, as a Buffer
app.post(
  "/webhooks/provider",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.get("X-Signature-256")
    if (!verify(req.body, signature)) {   // req.body is a Buffer here
      return res.sendStatus(400)
    }
    const event = JSON.parse(req.body.toString("utf8"))
    // ... handle event
    res.sendStatus(200)
  }
)

// global JSON parser for everything else, registered AFTER
app.use(express.json())

The webhook route now hashes req.body as the exact Buffer that arrived. Only after verification passes do you parse it yourself.

Step 2: Compute the HMAC over the raw buffer with a constant-time compare

const crypto = require("crypto")

function verify(rawBody, headerSig) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", process.env.WEBHOOK_SECRET)
          .update(rawBody)               // the Buffer, untouched
          .digest("hex")
  const a = Buffer.from(expected)
  const b = Buffer.from(headerSig || "")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

Two details that matter: hash the raw Buffer directly, never a re-stringified object; and compare with crypto.timingSafeEqual, not ===, so you are not leaking timing information about the secret. Match the exact header format your provider uses; some prefix sha256=, some do not, some use a comma-separated scheme with a timestamp.

Step 3: For frameworks with a single body pipeline, preserve the raw buffer

If you cannot easily scope the parser (some setups run one global parser), keep the raw bytes alongside the parsed body using the parser's verify hook, then hash the saved buffer:

app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf }   // stash raw bytes
}))
// later: verify(req.rawBody, signature), not verify(req.body, signature)

On other stacks the same principle holds. In a Next.js route handler, read the raw body with await req.text() and hash that string before you JSON.parse it, rather than reading await req.json() which discards the exact bytes. The rule is identical everywhere: get the untouched body, verify, then parse.

Verification: prove it is the body, not the secret

Before you change anything, prove the diagnosis so you do not chase the secret again. When a verification fails, log the computed signature and the received signature, never the body itself:

if (!ok) {
  console.warn("webhook sig mismatch", {
    computed: expected.slice(0, 16),   // prefixes only, never full or body
    received: (headerSig || "").slice(0, 16),
  })
}

Then reproduce it deliberately. Take one payload that failed and one that passed, and run both through your verification with the raw bytes preserved versus with a JSON.stringify(JSON.parse(body)) round-trip.

  1. With the raw bytes, both payloads verify. That proves the secret and the algorithm are correct.
  2. With the round-trip, the "random" failing payload fails and the passing one passes. That proves the parser is the cause.
  3. After deploying the raw-body fix, replay the provider's stored events (most dashboards let you resend). Every one should verify. A run of resends with zero mismatches is the fix confirmed.

What people get wrong

Rotating the signing secret. This is the reflex the failure invites, and it changes nothing, because the secret was never wrong. You will rotate it, watch a few events pass by luck, declare victory, and hit the same wall when the next awkward payload arrives. Worse, you have now added a secret rotation to your incident timeline that muddies the real diagnosis.

Canonicalising the JSON to make it match. Some people try to reconstruct the provider's exact serialization: sort keys, strip whitespace, re-escape Unicode. This is a losing game. You cannot reliably reproduce another system's byte output from a parsed object, and you should not try. Verify the bytes you received; do not manufacture bytes to match.

Comparing signatures with ===. Even once you hash the right bytes, a plain string comparison is timing-variable and leaks information about the secret over many attempts. Use a constant-time comparison. This is cheap and there is no reason not to.

Trusting the event before verifying it. Order also matters for security, not just correctness. Verify before you parse and before you act. A verified signature is the only thing standing between your handler and anyone who can POST to that URL, which is the whole point of not trusting anyone who can curl your endpoint.

When it is still broken

  • A proxy or WAF is rewriting the body. If an upstream reverse proxy re-encodes or reformats the request before it reaches your app, the bytes you receive are not the bytes that were signed, and no code change on your side fixes that. Confirm by hashing at the very edge; if it fails even there, the proxy is the culprit and must forward the body untouched.
  • Content-encoding or charset mismatch. If the provider signed a gzipped or specific-charset body and your framework silently decoded it, you are hashing the decoded form. Hash what actually arrived on the wire.
  • You are hashing a string, not the buffer, and Node re-encoded it. Converting the buffer to a string and back can alter bytes for some encodings. Keep it a Buffer all the way into the HMAC.
  • Signatures still mismatch on every event, not just some. That is a different bug: wrong secret, wrong algorithm, wrong header, or signing the URL/timestamp scheme incorrectly. An every-event failure is not the body-parser problem; re-read the provider's signing spec for the exact string they sign, which for some providers is a concatenation of timestamp and body, not the body alone.

The one sentence to keep: a webhook signature is a promise about exact bytes, and the moment a parser touches those bytes the promise is void. Verify first, parse second, and the intermittent failures disappear along with the urge to rotate a secret that was fine all along. For the broader picture of building endpoints that survive retries and duplicates, see the API design mistakes that cost six months.

Frequently asked questions

Why does my webhook signature verification fail only on some events?
Because a body-parsing middleware is running before your verification, so you end up hashing a re-serialized version of the body instead of the raw bytes the provider signed. JSON.stringify(JSON.parse(body)) does not reproduce the original bytes for every payload; differences in whitespace, key order, Unicode escaping or number formatting flip the hash. Payloads that happen to round-trip cleanly pass, which makes it look intermittent. The fix is to verify against the exact raw body before any parsing runs.
Does rotating the webhook signing secret fix random verification failures?
No. If failures correlate with payload content rather than time, the secret is not the problem. The signature is being computed over re-serialized bytes that differ from what the provider signed, so no secret will match. Rotating it wastes time and adds noise to your incident timeline. Fix the middleware order so verification sees the raw request bytes instead.
How do I preserve the raw body for webhook verification in Express?
Scope express.raw({ type: 'application/json' }) to the webhook route so req.body arrives as the untouched Buffer, register the global express.json() after it, verify the HMAC against that Buffer, and only then JSON.parse it yourself. If you cannot scope the parser, use the express.json verify callback to stash the raw buffer on req.rawBody and hash that instead of req.body.
Can I canonicalise the JSON to make signature verification work?
No, do not try. You cannot reliably reproduce another system's exact byte serialization from a parsed object, because key order, whitespace and Unicode escaping are not recoverable. Verify the exact bytes you received rather than manufacturing bytes to match. Read the raw request body, compute the HMAC over it, and parse only after verification passes.
#Webhooks#HMAC#Express#Signature Verification#Node.js#Raw Body
Keep reading

Related articles