Verify Webhook HMAC Signatures Safely
Sign and verify webhook payloads with HMAC-SHA256, timestamp tolerance and constant-time comparison to block replay attacks.
Code
import { createHmac, timingSafeEqual } from "node:crypto";
export interface SignatureOptions {
readonly secret: string;
readonly toleranceSeconds?: number;
readonly now?: number;
}
export function signWebhook(payload: string, timestamp: number, secret: string): string {
if (typeof secret !== "string" || secret.length < 16) {
throw new RangeError("secret must contain at least 16 characters");
}
if (!Number.isInteger(timestamp)) throw new TypeError("timestamp must be an integer");
const signature = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
return `t=${timestamp},v1=${signature}`;
}
export function verifyWebhook(payload: string, header: string, options: SignatureOptions): boolean {
if (typeof header !== "string") throw new TypeError("header must be a string");
const { secret, toleranceSeconds = 300, now = Date.now() } = options;
if (typeof secret !== "string" || secret.length < 16) {
throw new RangeError("secret must contain at least 16 characters");
}
const parts = new Map<string, string>();
for (const part of header.split(",")) {
const equals = part.indexOf("=");
if (equals === -1) continue;
parts.set(part.slice(0, equals).trim(), part.slice(equals + 1).trim());
}
const timestampText = parts.get("t");
const provided = parts.get("v1");
if (timestampText === undefined || provided === undefined) return false;
const timestamp = Number(timestampText);
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(now / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
const left = fromHex(expected);
const right = fromHex(provided);
if (left.length === 0 || left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
function fromHex(value: string): Uint8Array {
if (value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) return new Uint8Array(0);
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
}
return bytes;
}
const secret = "0123456789abcdef0123456789abcdef";
const payload = JSON.stringify({ event: "invoice.paid", id: "evt_42" });
const header = signWebhook(payload, 1760000000, secret);
console.log(header);
console.log(verifyWebhook(payload, header, { secret, now: 1760000100 * 1000 }));
console.log(verifyWebhook(`${payload} `, header, { secret, now: 1760000100 * 1000 }));
console.log(verifyWebhook(payload, header, { secret, toleranceSeconds: 10, now: 1760000100 * 1000 }));Report a problem with this exampleOpens a pre-filled issue on GitHub.