typescript 99 lines · 3 tabs

Webhook signature verification (timing-safe compare)

Shared by codesnips Jan 2026
3 tabs
import crypto from 'crypto';

interface VerifyOptions {
  rawBody: Buffer;
  signatureHeader: string | undefined;
  secret: string;
  toleranceSeconds?: number;
}

function parseHeader(header: string): { timestamp: number; signature: string } | null {
  const parts = header.split(',').map((p) => p.trim());
  let timestamp: number | null = null;
  let signature: string | null = null;
  for (const part of parts) {
    const [key, value] = part.split('=');
    if (key === 't') timestamp = Number(value);
    if (key === 'v1') signature = value;
  }
  if (timestamp === null || Number.isNaN(timestamp) || !signature) return null;
  return { timestamp, signature };
}

function constructExpectedSignature(timestamp: number, rawBody: Buffer, secret: string): string {
  const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
  return crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');
}

export function verifyWebhook(opts: VerifyOptions): boolean {
  const tolerance = opts.toleranceSeconds ?? 300;
  if (!opts.signatureHeader) return false;

  const parsed = parseHeader(opts.signatureHeader);
  if (!parsed) return false;

  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - parsed.timestamp) > tolerance) return false;

  const expected = constructExpectedSignature(parsed.timestamp, opts.rawBody, opts.secret);
  const expectedBuf = Buffer.from(expected, 'hex');
  const providedBuf = Buffer.from(parsed.signature, 'hex');

  // timingSafeEqual throws on length mismatch, so guard first.
  if (expectedBuf.length !== providedBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, providedBuf);
}
3 files · typescript Explain with highlit

Webhook endpoints are public URLs, so any request that arrives must be proven to come from the real sender before its payload is trusted. The standard defense is an HMAC signature: the provider signs the raw request body with a shared secret and sends the digest in a header, and the receiver recomputes the same digest and compares. This snippet shows that verification implemented the way Stripe and GitHub do it, with the two subtle details that trip people up: comparing bytes in constant time, and signing the raw body rather than the parsed JSON.

In verifyWebhook.ts, constructExpectedSignature builds the signed payload as ${timestamp}.${rawBody} and runs it through crypto.createHmac('sha256', secret). The comparison uses crypto.timingSafeEqual rather than ===. A naive string compare returns as soon as two characters differ, and that early-exit leaks timing information an attacker can measure to recover the signature byte by byte. timingSafeEqual always examines every byte, so the compare time is independent of where the mismatch is. It throws if the two buffers differ in length, so the code guards with a length check first and treats any length mismatch as a failure.

The function also enforces a toleranceSeconds window on the timestamp. Without this, a valid signature captured off the wire could be replayed forever; rejecting old timestamps bounds the replay window. The header is parsed defensively because a malformed or missing header should fail closed, not throw.

In rawBody.ts, express.raw captures the exact bytes of the body before any JSON parser mutates them. This matters because re-serializing parsed JSON can reorder keys or change whitespace, producing a different digest than the sender computed. The raw Buffer is what gets both verified and, only after verification, parsed.

In stripeWebhook.ts, the route wires it together: it verifies first, returns 400 on any failure, and only then parses and dispatches. It also derefs an idempotency key from the event id so a redelivered event is processed once. The trade-off is that this middleware must run before global body parsers, which is why the raw handler is mounted per-route rather than app-wide.


Related snips

Share this code

Here's the card — post it anywhere.

Webhook signature verification (timing-safe compare) — share card
Link copied