Your endpoint is public, so anyone can POST to it. Verify the signature on every request before you trust a single field.
webhook-id: evt_01JQ8Z3M4K webhook-timestamp: 1789012345 webhook-signature: v1,K5f9r…
import crypto from "node:crypto";
function verify(headers, rawBody, secret) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
// Reject anything older than five minutes — a valid signature
// replayed a week later is still an attack.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "v1," + crypto
.createHmac("sha256", secret)
.update(`${id}.${ts}.${rawBody}`)
.digest("hex");
// Constant-time: a plain === leaks the signature byte by byte.
const a = Buffer.from(expected), b = Buffer.from(headers["webhook-signature"]);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Verifying against a parsed and re-serialised body rather than the raw bytes — key order changes and the signature fails. Comparing signatures with `===`, which is timing-attackable. And skipping the timestamp check, which leaves you open to replay.
An endpoint can hold two secrets at once. Add the new one, deploy your verifier to accept either, then remove the old one.