webhooks · Sep 30, 2026
Verify Webhooks: Signatures, Timestamps, Tests
A webhook endpoint that trusts any POST is an open door. A 20-line HMAC verifier with a replay window, four tests that prove it, and the three mistakes that quietly break it.
Somewhere in your app there's a URL that a payment processor or a git host calls when something happens. If that endpoint believes whatever arrives, anyone who finds the URL can tell your app that an invoice was paid. The defence is old and small: the sender signs the body with a secret you both hold, and you check the signature before doing anything.
This tutorial builds that check in Node using only node:crypto, then proves it with tests. The scheme is the common shape rather than any one provider's: a header carrying a timestamp and an HMAC of the timestamp joined to the body.
The verifier
import { createHmac, timingSafeEqual } from "node:crypto";
// Header looks like: t=1767225600,v1=<hex hmac of "t.body">
// body can be a string or the raw Buffer; the bytes are signed as they arrived.
export function sign(secret, body, t = Math.floor(Date.now() / 1000)) {
const mac = createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
return `t=${t},v1=${mac}`;
}
export function verify(secret, body, header, { toleranceSec = 300, now = Date.now() } = {}) {
const parts = Object.fromEntries(
String(header ?? "").split(",").map((kv) => kv.split("=", 2)),
);
const ts = parts.t ?? "";
if (!/^[0-9]{1,12}$/.test(ts) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return "malformed";
if (Math.abs(now / 1000 - Number(ts)) > toleranceSec) return "stale";
const want = createHmac("sha256", secret).update(`${ts}.`).update(body).digest();
const got = Buffer.from(parts.v1, "hex");
return timingSafeEqual(want, got) ? "ok" : "bad-signature";
}Four choices are worth defending. The timestamp is inside the signed text, so an attacker can't swap in a fresh time on an old message. The comparison uses timingSafeEqual, so response timing doesn't leak how many leading bytes matched. The body goes into the HMAC as the bytes that arrived, whether you hold a string or a Buffer, because decoding binary data to text and back can change it. And verify returns a reason, not a boolean, so your logs can distinguish a forgery from a clock problem.
Notice the order. Format is checked before the signature, which also guarantees both buffers are the same length, because timingSafeEqual throws on a mismatch. The 64-character hex check is what makes that safe. The timestamp must be plain digits and is signed exactly as written in the header, so 0x... and 1767225600.0 spellings are rejected as malformed rather than quietly accepted.
The tests
Every branch gets one. Time is passed in, not read from the clock, so nothing here is flaky:
import { test } from "node:test";
import assert from "node:assert/strict";
import { sign, verify } from "./verify.mjs";
const secret = "demo-secret-not-real";
const body = '{"event":"invoice.paid","id":"evt_1"}';
const now = 1_767_225_600_000;
const at = (offsetSec) => sign(secret, body, now / 1000 + offsetSec);
test("fresh, untouched payload passes", () => {
assert.equal(verify(secret, body, at(0), { now }), "ok");
});
test("one changed byte fails", () => {
assert.equal(verify(secret, body.replace("evt_1", "evt_2"), at(0), { now }), "bad-signature");
});
test("a replay ten minutes later is stale", () => {
assert.equal(verify(secret, body, at(0), { now: now + 600_000 }), "stale");
});
test("garbage header is malformed, not a crash", () => {
assert.equal(verify(secret, body, "nonsense", { now }), "malformed");
assert.equal(verify(secret, body, undefined, { now }), "malformed");
});Running node --test verify.test.mjs printed four ok lines and a summary of 4 tests, 4 passed, 0 failed.
Where it goes in a handler
Put the check first, before parsing, before touching the database, before logging the payload. Read the raw body, call verify with the secret from your environment, and answer anything other than ok with a 400 and no explanation in the response. The reason belongs in your own log, not in the reply: a caller probing your endpoint shouldn't learn whether it got the format, the clock or the signature wrong.
One more habit pays off. Keep the secret out of the repository and rotate it when anyone who knew it leaves. Rotation is painless if the verifier accepts two secrets for a day, trying the new one first, so write that in before you need it, not after.
The three mistakes that break it
Verifying a re-serialized body. Sign and verify the exact bytes that arrived. If your framework parses the JSON first and you stringify it again, whitespace and key order change and every valid message fails. Read the raw body.
Skipping the replay window. Without a timestamp, a captured valid request works forever. With one, it works for five minutes. That is still a window, so make the handler idempotent: processing the same event id twice must be harmless.
Logging the secret, or comparing with
===. The first leaks it, the second leaks timing. Neither shows up in a test unless you write the test.
This is a starting point, not a certification. If your provider publishes a verification library, use it, and use this to understand what it does.
No comments yet
Comments are open. Have a thought or a question? Share it below.