Skip to main content

Verifying Webhook Payload Signatures in Contract Tests

Symptom: the sender’s documentation says the signature is HMAC-SHA256(timestamp + "." + body), you implemented exactly that, and roughly a third of deliveries fail verification — with no pattern you can see. This guide is part of Webhook and Callback Contracts and covers the receiving side, where almost every signature bug actually lives.

Root Cause: You Are Not Hashing the Bytes That Were Signed

The sender computed the signature over the exact bytes it put on the wire. If your framework parsed the JSON before your handler ran, and your verification code re-serialised the parsed object, you are hashing a different byte sequence that happens to represent the same data.

The differences are invisible in a debugger and fatal to a hash: key order, whitespace, unicode escaping, and how a floating-point number renders.

Same data, different bytes, different hash The received bytes and the re-serialised bytes carry identical information but differ in key order and spacing, so the two HMAC results share nothing. Only the received bytes can reproduce the sender's signature. the bytes that arrived {"id":"evt_9","amount":42.0} no spaces, sender's key order parsed, then re-serialised {"amount":42,"id":"evt_9"} reordered, trailing zero lost v1=4f2c9a… matches the header v1=b81e07… matches nothing A hash has no notion of "the same data" — only of the same bytes.

Step 1: Capture the Raw Body

Mount a raw parser on the webhook route only, so the rest of the application keeps its normal JSON parsing.

// receiver.ts — Express 4
import express from "express";
const app = express();

// Everything else keeps the parsed-JSON convenience...
app.use("/api", express.json());

// ...but the webhook route sees bytes.
app.post("/webhooks",
  express.raw({ type: "application/json", limit: "1mb" }),
  handleWebhook);

Why this works: express.raw leaves req.body as a Buffer holding exactly what arrived, which is the only input that can reproduce the sender’s signature.

Step 2: Reject Stale Timestamps Before Doing Any Cryptography

Order matters: check the cheap thing first, so a flood of replayed deliveries cannot cost you HMAC computations.

// webhooks/verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export type VerifyResult = "ok" | "stale" | "malformed" | "mismatch";

export function verifyDelivery(
  rawBody: Buffer,
  signatureHeader: string | undefined,
  timestampHeader: string | undefined,
  secrets: string[],              // current first, previous second, during rotation
  now = Date.now(),
): VerifyResult {
  if (!signatureHeader || !timestampHeader) return "malformed";

  const sentAt = Date.parse(timestampHeader);
  if (!Number.isFinite(sentAt)) return "malformed";
  if (Math.abs(now - sentAt) / 1000 > TOLERANCE_SECONDS) return "stale";

  // The signed string is the timestamp, a separator, then the raw bytes.
  for (const secret of secrets) {
    const mac = createHmac("sha256", secret);
    mac.update(`${timestampHeader}.`);
    mac.update(rawBody);                 // Buffer in, no string conversion
    const expected = Buffer.from(`v1=${mac.digest("hex")}`);
    const presented = Buffer.from(signatureHeader);
    // Length check first: timingSafeEqual throws on a length mismatch.
    if (expected.length === presented.length && timingSafeEqual(expected, presented)) {
      return "ok";
    }
  }
  return "mismatch";
}

Two details earn their place. The loop over secrets is what makes rotation a non-event — during an overlap the endpoint accepts either. And timingSafeEqual guarded by a length check avoids both the timing leak and the exception that a bare call throws on differing lengths.

Step 3: Wire It Into the Handler

// webhooks/handler.ts
export async function handleWebhook(req: express.Request, res: express.Response) {
  const result = verifyDelivery(
    req.body as Buffer,
    req.get("X-Signature"),
    req.get("X-Signature-Timestamp"),
    [process.env.WEBHOOK_SECRET!, process.env.WEBHOOK_SECRET_PREVIOUS ?? ""].filter(Boolean),
  );

  if (result !== "ok") {
    // Log the reason for us; tell the sender nothing beyond 401.
    logger.warn({ msg: "webhook signature rejected", reason: result });
    return res.sendStatus(401);
  }


  // ONLY now is it safe to parse.
  const event = JSON.parse((req.body as Buffer).toString("utf8"));
  res.sendStatus(202);                     // acknowledge before doing work
  void queue.enqueue(event);
}
The order the handler must follow Raw bytes are verified first, parsed second, acknowledged third and processed last, so an unverified payload is never parsed and a slow processor never causes a retry. 1. verify raw bytes only 2. parse now it is safe 3. acknowledge 202, in milliseconds 4. process off the request path Swapping steps 1 and 2 is the raw-body bug; swapping 3 and 4 is the retry-storm bug.

Responding with a bare 401 is deliberate. A body explaining why verification failed helps an attacker calibrate and helps a legitimate sender not at all — they have your documentation.

Step 4: Test the Whole Scheme

Five cases cover it. Fixtures make them deterministic.

// webhooks/verify.test.ts
import { verifyDelivery } from "./verify";
import { createHmac } from "node:crypto";

const SECRET = "whsec_test_only";
const BODY = Buffer.from('{"id":"evt_9","type":"order.paid","amount":42.0}');
const TS = "2026-07-29T10:00:00.000Z";
const NOW = Date.parse(TS);

function signWith(secret: string, body: Buffer, ts: string) {
  const mac = createHmac("sha256", secret);
  mac.update(`${ts}.`);
  mac.update(body);
  return `v1=${mac.digest("hex")}`;
}

test("accepts a correctly signed delivery", () => {
  expect(verifyDelivery(BODY, signWith(SECRET, BODY, TS), TS, [SECRET], NOW)).toBe("ok");
});

test("rejects a body altered by a single byte", () => {
  const tampered = Buffer.from('{"id":"evt_9","type":"order.paid","amount":43.0}');
  expect(verifyDelivery(tampered, signWith(SECRET, BODY, TS), TS, [SECRET], NOW)).toBe("mismatch");
});

test("rejects a replay outside the tolerance window", () => {
  const sixMinutesLater = NOW + 6 * 60_000;
  expect(verifyDelivery(BODY, signWith(SECRET, BODY, TS), TS, [SECRET], sixMinutesLater)).toBe("stale");
});

test("rejects a signature made with the wrong secret", () => {
  expect(verifyDelivery(BODY, signWith("whsec_other", BODY, TS), TS, [SECRET], NOW)).toBe("mismatch");
});

test("accepts the previous secret during a rotation overlap", () => {
  const old = "whsec_previous";
  expect(verifyDelivery(BODY, signWith(old, BODY, TS), TS, [SECRET, old], NOW)).toBe("ok");
});

Note the tampered-body test changes exactly one character. That is the case a re-serialising implementation passes by accident, which is why it belongs in the suite.

The five cases a signature suite must cover Valid delivery accepted; tampered body, wrong secret and stale timestamp each rejected with a distinct reason; and a delivery signed with the previous secret accepted during rotation. case expected what it protects correctly signed ok the happy path works at all one byte changed in the body mismatch catches re-serialisation timestamp six minutes old stale bounds replay attacks signed with another secret mismatch the secret is actually checked signed with the previous secret ok rotation drops nothing

Verification

$ npm test -- webhooks/verify.test.ts

 PASS  webhooks/verify.test.ts
  ✓ accepts a correctly signed delivery (3 ms)
  ✓ rejects a body altered by a single byte (1 ms)
  ✓ rejects a replay outside the tolerance window
  ✓ rejects a signature made with the wrong secret (1 ms)
  ✓ accepts the previous secret during a rotation overlap

Tests: 5 passed, 5 total

Against a live sender, the end-to-end check is that a real delivery is accepted and a replayed capture is not:

# Replay a captured delivery an hour later — must be rejected as stale.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/webhooks \
  -H "X-Signature: $CAPTURED_SIG" -H "X-Signature-Timestamp: $CAPTURED_TS" \
  --data-binary @captured-body.json
# 401

Edge Cases and Caveats

  • Body-size limits. A raw parser with a small limit silently truncates, and a truncated body never verifies. Set the limit above the sender’s documented maximum payload size and monitor rejections for a size correlation.
  • Proxies that rewrite bodies. Some API gateways normalise JSON or strip a trailing newline. If verification fails only in one environment, capture the bytes at the receiver and diff them against the sender’s copy — that comparison usually ends the investigation immediately.
  • Multiple signatures in one header. Some senders send several, comma-separated, during a key rotation. Parse and try each rather than assuming one value.
  • Clock drift. A receiver whose clock is minutes off rejects everything as stale. Run NTP, and log the computed age on rejection so the pattern is obvious in the first minute of investigation.
  • Secrets in test fixtures. Use an obviously fake secret in tests and make sure your secret scanner knows the prefix. The test above uses whsec_test_only for exactly this reason.

Choosing Between Shared Secrets and Asymmetric Signatures

The scheme above uses an HMAC with a shared secret, which is the common choice and the right default. It is worth knowing when it is not.

A shared secret means both parties can produce a valid signature, not just verify one. For most webhook relationships that is irrelevant — the receiver has no reason to forge deliveries to itself. It matters when a delivery is forwarded: if a receiver passes events on to a third party, that party cannot distinguish a genuine delivery from one the receiver minted, because the receiver holds the signing key.

An asymmetric signature removes that ambiguity. You sign with a private key, publish the public half, and any number of parties can verify without being able to forge. The costs are a slower verification, a key distribution problem, and a rotation story that now involves publishing a key set rather than sharing a string.

The practical rule: shared secrets for point-to-point delivery, asymmetric signatures when a delivery is expected to be forwarded or archived as evidence.

Operating the Scheme

Two operational details decide whether the scheme survives contact with production.

Log the rejection reason, never the payload. A rejected delivery should produce a log line naming the event id and the failure class — stale, mismatch, malformed. Logging the body of a rejected delivery is how an unverified payload ends up in your log store, which is precisely what verification exists to prevent.

Alert on a rejection rate, not on a rejection. A single mismatch is usually a misconfigured receiver in a staging environment. A sustained rate is either an attack or a broken deployment on one side, and both deserve attention within minutes rather than at the next log review.

Frequently Asked Questions

Why does my signature check fail even though the secret is right?

Almost always because the body was parsed and re-serialised before verification. JSON.parse followed by JSON.stringify changes key order, whitespace and number formatting, so the bytes you hash are not the bytes that were signed. Verify against the raw buffer, then parse.

Should the timestamp be inside the signed material?

Yes. Signing the body alone lets anyone who captured a delivery replay it forever. Including the timestamp in the signed string and rejecting anything older than a few minutes bounds the replay window.

Why compare signatures in constant time?

A normal string comparison returns as soon as it finds a differing byte, and the time it takes leaks how many leading bytes were correct. Over many attempts that is enough to reconstruct a signature. Use timingSafeEqual, and compare lengths before it to avoid throwing.

How do I rotate a webhook signing secret without dropping deliveries?

Support two active secrets per endpoint. Sign with the new one, accept either during the overlap, and drop the old one once no in-flight retry can still carry it — which is one full retry-schedule length.

Should a failed signature return 401 or 400?

  1. The delivery failed to authenticate. Return it quickly and log the event id, but do not include any detail about why the signature failed — that information helps only an attacker.