Demo, all content is generated
Question

Webhook signature verification always fails in my Express server

Solved · 614 views · asked by nordic_nick · edited

Receiving webhooks from a payment/forms provider (HMAC SHA256 signature in a header). I compute the HMAC exactly like their docs and it never matches:

app.use(express.json());
app.post("/webhook", (req, res) => {
  const sig = crypto.createHmac("sha256", SECRET).update(JSON.stringify(req.body)).digest("hex");
  if (sig !== req.headers["x-signature"]) return res.sendStatus(401);
  ...
});
What I’ve tried

Checked the secret three times, tried base64 instead of hex. Claude Code suggested removing the check "for now".

Comment
Are you using express.json() globally? chidi_eze · edited

3 answers

Marked as helpful by the asker
chidi_eze · edited

JSON.stringify(req.body) isn't the bytes they signed. Parsing and re-serializing changes whitespace and key order. You need the raw body:

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const given = String(req.headers["x-signature"] ?? "");
  const ok = given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));
  ...
});

Register this route before the global express.json(), or that middleware eats the body first. And please don't remove the check, without it anyone can POST fake events.

Comment
moved it above express.json with express.raw and it matches. keeping the check, promise nordic_nick · edited
Worth a test with a captured real payload and signature so it never regresses. katja_s · edited
lena_ops · edited

And if the provider sends a timestamp header, reject events older than ~5 minutes. A valid signature on an old captured request is still a valid signature (replay).

Comment
sarah_k_dev · edited

Some providers also sign timestamp + "." + body, not just the body. Worth double checking the exact string in their docs.

Comment