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.
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);
}
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.
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_onlyfor 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?
- 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.