DEV Community

Libme
Libme

Posted on

Webhook Signature Verification Fails: Your Framework Already Parsed the Body

If a webhook signature check fails and you are sure the secret is right, the body your code is hashing is almost never the body the sender hashed. Something between the socket and your handler parsed the JSON and re-serialized it, and HMAC does not forgive a single changed byte. The fix is always the same shape: get the raw request bytes before any body parser runs, hash those, and compare with a constant-time function.

This post walks through the failure as it shows up with Stripe and GitHub, why the obvious workaround (JSON.stringify(req.body)) fails in ways that look random, and the raw-body recipe for each framework I have had to do this in.

What does the error actually look like?

With stripe-node the message is explicit, which is why it ends up in so many search queries:

Webhook Error: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
Enter fullscreen mode Exit fullscreen mode

GitHub gives you nothing that helpful. Your own check just returns false, the delivery shows a 401 in the repository's webhook settings, and you start doubting the secret. A quick way to rule the secret out: copy the raw payload from GitHub's "Recent Deliveries" tab, HMAC it locally with the secret, and compare with the X-Hub-Signature-256 header shown on the same page. If that matches and your server still rejects it, the server is hashing different bytes.

Both senders sign the exact byte sequence they put on the wire. Stripe signs ${timestamp}.${rawBody} with HMAC-SHA256 and sends t=...,v1=... in the Stripe-Signature header. GitHub signs the raw body and sends sha256=<hex> in X-Hub-Signature-256. Neither signs "the JSON document" in any abstract sense.

The signature is over bytes, not over the data those bytes encode, so any step that decodes and re-encodes the body invalidates it.

Why does re-serializing the parsed body not work?

The first thing most people try is JSON.stringify(req.body) and hoping it round-trips. It sometimes does, which is the worst outcome, because it passes in tests and fails in production on the first payload that contains a float, a non-ASCII character, or whitespace the sender happened to emit.

const raw = '{"amount": 1000, "note": "caf\\u00e9", "ratio": 1.0}';
JSON.stringify(JSON.parse(raw));
// '{"amount":1000,"note":"café","ratio":1}'
Enter fullscreen mode Exit fullscreen mode

Three differences from one line: the spaces after colons are gone, é became a literal é, and 1.0 became 1. Any one of them changes the hash. Python's json.dumps has its own defaults (spaces after separators, ensure_ascii=True) that differ from JavaScript's, so a Node sender and a Python receiver will disagree even on the "same" object.

There is no serializer setting that reliably reproduces another system's output; the only byte-exact copy of the payload is the one you received.

How do I get the raw body in each framework?

The pattern is the same everywhere: read the body stream once, as bytes, before anything else consumes it. Where it goes wrong is framework-specific.

Framework Raw body access What silently breaks it
Express express.raw({ type: "application/json" }) on the webhook route only Registering app.use(express.json()) before the webhook route
Next.js App Router await request.text() in the route handler Calling request.json() first; the stream can be read once
Next.js Pages Router export const config = { api: { bodyParser: false } } and read the stream Forgetting the config export; the default parser runs
FastAPI / Starlette await request.body() Nothing serious; Starlette caches the body, so this is the easy one
Flask request.get_data() Using request.data, which is empty for form mimetypes
AWS Lambda (API Gateway) event.body, decode with base64 when event.isBase64Encoded is true Hashing the base64 string instead of the decoded bytes

Express deserves the full example because route order is the trap. The raw parser has to be attached to the webhook route and the JSON parser has to come after it, or be scoped so it never sees that path:

import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

// Webhook route first, with a raw parser. req.body is a Buffer here.
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }
    // handle event.type here
    res.sendStatus(200);
  }
);

// Everything else gets parsed JSON.
app.use(express.json());
Enter fullscreen mode Exit fullscreen mode

If you cannot reorder middleware (a shared app factory, a framework wrapper you do not control), the verify hook on express.json is the escape hatch. It runs with the original buffer before parsing and lets you stash it:

app.use(
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf;
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

Then verify against req.rawBody. This works for every route at once, at the cost of holding a second copy of each body in memory, which matters only if your JSON bodies are large.

For a GitHub-style HMAC where you write the check yourself, the comparison has to be constant-time and length-checked, because timingSafeEqual throws on mismatched lengths rather than returning false:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyGithubSignature(rawBody, headerValue, secret) {
  if (!headerValue) return false;
  const expected =
    "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(headerValue);
  return a.length === b.length && timingSafeEqual(a, b);
}
Enter fullscreen mode Exit fullscreen mode

And the Python equivalent, where hmac.compare_digest already handles the constant-time part:

import hashlib
import hmac
import os

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()


@app.post("/webhooks/github")
async def github_webhook(request: Request):
    raw = await request.body()  # bytes, exactly as received
    sig = request.headers.get("x-hub-signature-256", "")
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        raise HTTPException(status_code=401, detail="bad signature")
    payload = await request.json()  # safe to parse now
    ...
Enter fullscreen mode Exit fullscreen mode

Whichever framework you use, the webhook route should be the one place in the app where the body parser is explicitly bypassed, and the code should say so in a comment for the next person who "fixes" the middleware order.

What else changes the bytes between sender and handler?

Once the body parser is out of the way, the remaining causes are infrastructure. Two I have actually hit:

An API gateway with a body mapping template or JSON transformation enabled. Anything that reformats request bodies for downstream services is re-serializing them. The fix is to exempt the webhook path from the transformation, not to try to reverse it.

Lambda behind API Gateway with binary media types configured. The body arrives base64-encoded and isBase64Encoded is true; hashing event.body directly hashes the base64 text. Decode to a Buffer first, then hash.

Ordinary reverse proxies and CDNs do not touch bodies, so nginx, Caddy, or Cloudflare in front of the app are not suspects here. Compression is also not a suspect for inbound webhooks, because the sender does not gzip the request body and the proxy decompresses before your handler sees it anyway.

If the parser is bypassed and verification still fails, look for a component that advertises "transformation" or "mapping" of request bodies; it is doing exactly what it says.

How do I test this without waiting for real events?

For Stripe, the CLI's stripe listen --forward-to localhost:3000/webhooks/stripe gives you a local signing secret and replays real event shapes, which is the fastest loop I know for this class of bug; its limitation is that it only speaks Stripe. For any other sender, expose the local port with ngrok or Cloudflare Tunnel and use the provider's "redeliver" button. If you want a hop that records every delivery and lets you replay it against a rebuilt handler, Hookdeck is the one that sits in front of your endpoint and keeps the raw payload and headers, at the cost of adding an external dependency on the path that has to be up when the payment provider retries.

Whatever you use, write one unit test that feeds a hard-coded raw payload and a signature you computed yourself, so a future middleware change fails in CI rather than on a customer's checkout.

FAQ

Why does Stripe say "No signatures found matching the expected signature for payload" in Express?
Because express.json() parsed the body before constructEvent ran, so the handler hashed a re-serialized object instead of the bytes Stripe signed. Attach express.raw({ type: "application/json" }) to the webhook route and register it before the JSON parser.

Can I verify a webhook signature from a parsed JSON body?
No. Serializers normalize whitespace, unicode escapes, and number formatting, so the output is not byte-identical to what the sender signed. You need the original request bytes.

Should I compare the signature with ===?
No. Use crypto.timingSafeEqual in Node or hmac.compare_digest in Python, and check lengths first in Node because timingSafeEqual throws on different-length inputs.

Bottom line

If you own the app, put the raw-body parser on the webhook route and the JSON parser after it, and treat that ordering as load-bearing. If you cannot control middleware order, use the parser's verify hook to capture the buffer for every request. If the parser is already out of the picture and signatures still fail, the culprit is a gateway or serverless layer re-encoding the body, not your secret. In every case, lock the behavior in with a test that uses a fixed payload and a precomputed signature.

Related reading

Top comments (0)