All code helpers
BackendTypeScript

Verify webhook HMAC signatures

Confirm that an incoming webhook was sent by the provider and wasn't replayed. Uses a constant-time comparison and a timestamp tolerance, the same scheme Stripe and many others use.

#webhooks#security#hmac#crypto
lib/webhook-signature.ts
import { createHmac, timingSafeEqual } from "node:crypto";

type VerifyInput = {
  /** The raw request body, exactly as received */
  payload: string | Buffer;
  /** Header in the form "t=1700000000,v1=<hex signature>" */
  header: string | null;
  secret: string;
  /** Reject signatures older than this to block replays */
  toleranceSec?: number;
};

function sign(secret: string, timestamp: number, payload: string | Buffer): Buffer {
  return createHmac("sha256", secret).update(`${timestamp}.`).update(payload).digest();
}

export function verifyWebhookSignature({
  payload,
  header,
  secret,
  toleranceSec = 300,
}: VerifyInput): boolean {
  if (!header) return false;

  const parts = new Map(
    header.split(",").map((part) => {
      const [key, ...rest] = part.trim().split("=");
      return [key, rest.join("=")] as const;
    }),
  );

  const timestamp = Number(parts.get("t"));
  const signature = parts.get("v1");
  if (!Number.isInteger(timestamp) || !signature) return false;

  const age = Math.abs(Date.now() / 1000 - timestamp);
  if (age > toleranceSec) return false;

  const expected = sign(secret, timestamp, payload);
  const received = Buffer.from(signature, "hex");

  // timingSafeEqual throws on length mismatch, so check that first
  return received.length === expected.length && timingSafeEqual(received, expected);
}

/** Produce a header for tests or for webhooks you send yourself */
export function signWebhook(secret: string, payload: string): string {
  const timestamp = Math.floor(Date.now() / 1000);
  return `t=${timestamp},v1=${sign(secret, timestamp, payload).toString("hex")}`;
}

Usage

TypeScript
// app/api/webhooks/route.ts
import { verifyWebhookSignature } from "@/lib/webhook-signature";

export async function POST(req: Request) {
  const payload = await req.text(); // raw body, before any JSON parsing
  const ok = verifyWebhookSignature({
    payload,
    header: req.headers.get("x-signature"),
    secret: process.env.WEBHOOK_SECRET!,
  });
  if (!ok) return new Response("Invalid signature", { status: 401 });

  const event = JSON.parse(payload);
  // ...enqueue the event and return quickly
  return Response.json({ received: true });
}

How it works & gotchas

  • 01Always sign and verify the raw body. Parsing and re-serialising JSON changes whitespace and key order, and the signature will never match.
  • 02timingSafeEqual avoids leaking how many bytes matched through response timing. A plain === on strings does not.
  • 03The timestamp is part of the signed content, so an attacker can't replay an old request with a fresh timestamp.
  • 04Header names and formats differ per provider. Adjust the parsing, but keep the raw body, the HMAC and the constant-time compare.
  • 05Webhooks can still be delivered twice legitimately. Deduplicate on the event ID before acting on it.

Need this wired into a real system?

We build and harden production software end to end.

Talk to an engineer