Verify Webhook HMAC Signatures Safely

Sign and verify webhook payloads with HMAC-SHA256, timestamp tolerance and constant-time comparison to block replay attacks.

TypescriptSecurity Boundaries

Code

snippet
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 }));