DEV Community

Cover image for Webhook Signature Verification in Node.js: Hash the Raw Bytes
Karuha
Karuha

Posted on

Webhook Signature Verification in Node.js: Hash the Raw Bytes

A webhook HMAC is not a string compare on parsed JSON. Sign the exact bytes on the wire, put the timestamp inside that HMAC, compare with timingSafeEqual, and reject anything older than five minutes. That is the contract. In a backend interview, the follow-up is why each of those four steps exists.

I treat this as a small Node.js drill, not a slide. Five assertions. If one of them is hand-wavy, the answer is not ready.

What is the interviewer actually asking?

"How do you verify a webhook?" is a trap if you stop at "I check the signature header."

They want a receiver that can prove three things about a public POST endpoint:

  • the body was not rewritten in transit
  • the timestamp in the header was not swapped for "now"
  • a captured valid request cannot be replayed six minutes later

Stripe documents this model directly: the timestamp lives in the Stripe-Signature header, it is part of the signed payload, and the official libraries use a default tolerance of five minutes. Setting that tolerance to 0 does not mean "strict." It disables the recency check.

GitHub's version is slightly thinner. Validating webhook deliveries signs the raw body with HMAC-SHA256 and sends X-Hub-Signature-256: sha256=<hex>. There is no timestamp inside that HMAC, so replay protection is your problem. The Stripe-shaped contract is the more complete interview answer because it forces you to talk about replay.

flowchart LR
  A[Raw body bytes] --> B{HMAC over t.body}
  B -->|match and fresh| C[Accept]
  B -->|mismatch| D[Reject signature]
  B -->|stale timestamp| E[Reject replay]

How do you build the signed payload?

Keep the raw body as a string. Do not JSON.parse it first.

The signed material is timestamp + "." + rawBody. The header looks like t=<unix>,v1=<hex>. During secret rotation you may see more than one v1; accept the request if any of them matches.

import crypto from "node:crypto";

const TOLERANCE_SEC = 300;

function parseSignatureHeader(header) {
  const values = { t: null, v1: [] };
  for (const part of header.split(",")) {
    const eq = part.indexOf("=");
    if (eq < 0) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === "t") values.t = value;
    if (key === "v1") values.v1.push(value);
  }
  if (!values.t || values.v1.length === 0) {
    throw new Error("malformed signature header");
  }
  return values;
}

function hmacHex(secret, signedPayload) {
  return crypto
    .createHmac("sha256", secret)
    .update(signedPayload, "utf8")
    .digest("hex");
}

function timingSafeHexEqual(a, b) {
  const left = Buffer.from(a, "hex");
  const right = Buffer.from(b, "hex");
  if (left.length === 0 || left.length !== right.length) return false;
  return crypto.timingSafeEqual(left, right);
}

export function verifyWebhook({
  rawBody,
  header,
  secret,
  nowSec,
  toleranceSec = TOLERANCE_SEC,
}) {
  const { t, v1 } = parseSignatureHeader(header);
  const timestamp = Number(t);
  if (!Number.isInteger(timestamp)) {
    return { ok: false, reason: "timestamp" };
  }

  const expected = hmacHex(secret, `${t}.${rawBody}`);
  const signatureOk = v1.some((candidate) =>
    timingSafeHexEqual(expected, candidate)
  );
  if (!signatureOk) return { ok: false, reason: "signature" };

  if (Math.abs(nowSec - timestamp) > toleranceSec) {
    return { ok: false, reason: "replay" };
  }

  return { ok: true, reason: "accepted" };
}
Enter fullscreen mode Exit fullscreen mode

timingSafeEqual throws if the buffers differ in length. Check length first and return false. For SHA-256 hex that length is public (64 characters), so leaking it is fine. Comparing with === is the version that silently teaches a timing oracle.

Why does JSON.parse break a valid signature?

This is the bug I see in take-home webhooks and in interview sketches.

Express json() middleware parses the body. You then JSON.stringify(req.body) and HMAC that. The provider signed the bytes on the wire, including spaces and the trailing newline. V8 will not put those back.

import assert from "node:assert/strict";

const secret = "whsec_test_secret";
const now = 1_777_000_000;
const rawBody = '{ "id": "evt_1", "amount": 100 }\n';

function sign({ rawBody, secret, timestamp }) {
  const v1 = hmacHex(secret, `${timestamp}.${rawBody}`);
  return `t=${timestamp},v1=${v1}`;
}

const validHeader = sign({ rawBody, secret, timestamp: now });

assert.deepEqual(
  verifyWebhook({ rawBody, header: validHeader, secret, nowSec: now }),
  { ok: true, reason: "accepted" }
);

const reSerialized = JSON.stringify(JSON.parse(rawBody));
assert.notEqual(reSerialized, rawBody);
assert.deepEqual(
  verifyWebhook({
    rawBody: reSerialized,
    header: validHeader,
    secret,
    nowSec: now,
  }),
  { ok: false, reason: "signature" }
);
Enter fullscreen mode Exit fullscreen mode

On this machine those two strings are:

  • wire: { "id": "evt_1", "amount": 100 }\n
  • re-serialized: {"id":"evt_1","amount":100}

Same object. Different bytes. Different HMAC. The test fails on purpose, which is the point.

In production this means: capture the raw body before the JSON parser, verify, then parse.

What if someone rewrites the timestamp?

A captured request already has a valid v1. If you HMAC the body alone, an attacker can keep v1, set t to now, and walk past a freshness check.

Put t inside the signed payload. Then bumping the timestamp invalidates v1.

const bumped = `t=${now + 60},v1=${validHeader.split("v1=")[1]}`;
assert.deepEqual(
  verifyWebhook({ rawBody, header: bumped, secret, nowSec: now }),
  { ok: false, reason: "signature" }
);

const stale = sign({ rawBody, secret, timestamp: now - 301 });
assert.deepEqual(
  verifyWebhook({ rawBody, header: stale, secret, nowSec: now }),
  { ok: false, reason: "replay" }
);
Enter fullscreen mode Exit fullscreen mode

301 seconds is one second past Stripe's default window. The signature is valid. The request is still rejected. That split is the sentence interviewers are listening for: authenticity and recency are two checks, in that order.

Secret rotation is the last fixture. Stripe can send two v1 values while both secrets are live. Accept if any candidate matches the secret you currently trust.

const rotated = sign({ rawBody, secret: "whsec_new", timestamp: now });
const dualHeader = `${validHeader},${rotated.replace(/^t=\d+,/, "")}`;
assert.deepEqual(
  verifyWebhook({
    rawBody,
    header: dualHeader,
    secret: "whsec_new",
    nowSec: now,
  }),
  { ok: true, reason: "accepted" }
);
Enter fullscreen mode Exit fullscreen mode

Run the file with node verify.mjs. All five assertions should print nothing except your own log line.

What do you say when they ask about trade-offs?

Choice What you gain What you pay
Raw body, not JSON.stringify Signature matches the provider You must buffer the request before parsing
Timestamp inside the HMAC Attackers cannot freshen a captured t Clock skew becomes a production concern
300s tolerance Official Stripe default, NTP-friendly A 4-minute replay still lands
timingSafeEqual No byte-by-byte oracle You must guard buffer length
Multiple v1 values Zero-downtime secret rotation A leaked old secret stays valid until you drop it

The honest limitation: this drill is process-local. It does not replace event-id dedupe. Providers retry. A valid request can arrive twice inside the five-minute window. Signature verification proves who sent it, not that you have never processed evt_1 before. Idempotency on the event id is a separate contract.

Also return 2xx fast. Stripe will retry if you do the slow work on the request thread. Verify, enqueue, respond. Parse and fan-out after.

How I rehearse the live explanation

Once the assertions pass, the remaining failure mode is talking. "Walk me through this file" is the actual round, and it is where people who pasted a Stack Overflow snippet get stuck.

I keep a short script:

  1. Point at the raw-body buffer and say what would happen if Express parsed first.
  2. Point at ${t}.${rawBody} and explain why t is not a separate header check.
  3. Name the 301-second fixture and the 5-minute default from Stripe's webhook docs.
  4. Admit what this does not prove: duplicate evt_1 inside the window.

I use aceround.app — AI interview assistant to run that walkthrough out loud, with interruptions, until the four steps come out in a stable order. The code is the evidence. The interview is whether you can defend it.

FAQ

Can I HMAC JSON.stringify(req.body) if I control both sides?
Only if you also control canonicalization. You usually do not. Providers sign the wire bytes. Hash those.

Is timingSafeEqual overkill for an HMAC?
HMAC already stops forgery. The compare still should not leak which prefix matched. Use it. Check lengths first so Node does not throw.

Why not set tolerance to 0?
Stripe's own docs say 0 disables the recency check. That is the opposite of strict.

Does this replace an official SDK?
No. Use stripe.webhooks.constructEvent in production. The drill exists so you can explain what the SDK is doing when the interviewer takes it away.

Drafted with AI assistance, then edited. Every assertion in this article was executed with Node.js before publishing.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

Your approach to hashing the raw bytes for webhook verification is spot on, especially the emphasis on maintaining the integrity of the payload and preventing replay attacks. It’s interesting how different frameworks handle signature verification, and your comparison between Stripe and GitHub's methods highlights the importance of timestamp inclusion for robust security. If you're looking for any additional engineering support or alternative implementations in this area, I'd be happy to discuss a paid collaboration. What challenges have you faced when integrating this verification in larger, more complex systems?