← All insights

Engineering · 7 min read

Verify webhooks properly: HMAC, replay protection and idempotency

By BizDevHub ·

Any endpoint that triggers actions (booking a meeting, creating a CRM record, replying to a customer) must prove that the request came from who it claims. A webhook URL is not a secret. Here is a verifier that gets the details right, with tests we actually ran.

The four rules

  1. Sign the raw body. Compute the HMAC over the exact bytes received. If you JSON.parse first and re-serialise, whitespace or key-order changes will break verification, or worse, let a tampered body through a lenient comparison.
  2. Compare in constant time. A plain === on hex strings can leak how many leading characters matched. crypto.subtle.verify does the comparison safely.
  3. Bind a timestamp into the signature and enforce a window. Signing timestamp.body and rejecting requests older than five minutes stops an attacker replaying a captured valid request later.
  4. Be idempotent. Providers retry. Store each event ID with a unique constraint and ignore duplicates, so a retry never books two meetings.

Reference implementation (Cloudflare Workers / WebCrypto)

const enc = new TextEncoder();

const hexToBytes = (hex) => {
  if (!/^[0-9a-f]{64}$/i.test(hex)) return null; // SHA-256 = 32 bytes = 64 hex chars
  return Uint8Array.from(hex.match(/../g).map((h) => parseInt(h, 16)));
};

export async function verifyWebhook(request, secret, { toleranceSec = 300, now = Date.now() } = {}) {
  const ts = request.headers.get('x-timestamp') || '';
  const sig = hexToBytes(request.headers.get('x-signature') || '');
  if (!sig || !/^\d{10}$/.test(ts)) return null;
  if (Math.abs(now / 1000 - Number(ts)) > toleranceSec) return null; // replay window

  const body = await request.text(); // raw bytes as sent, before any JSON.parse
  const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
  const ok = await crypto.subtle.verify('HMAC', key, sig, enc.encode(`${ts}.${body}`)); // constant-time
  return ok ? body : null;
}

Notes on the choices: the hex string is validated with a strict 64-character pattern before decoding, because parseInt('zz', 16) silently returns NaN and a lenient decoder can turn garbage into a zero-filled "signature". The function returns the body only on success, so callers can't accidentally use an unverified payload.

We tested it

The verifier ships with six tests that run under Node's WebCrypto, which implements the same API as the Workers runtime. All six pass:

Idempotency in D1

CREATE TABLE IF NOT EXISTS events (
  id TEXT PRIMARY KEY,          -- provider's event id
  received_at TEXT NOT NULL
);
// returns false if we've already processed this event
const r = await env.DB.prepare(
  'INSERT OR IGNORE INTO events (id, received_at) VALUES (?, ?)'
).bind(eventId, new Date().toISOString()).run();
if (r.meta.changes === 0) return new Response('duplicate', { status: 200 });

Return 200 for duplicates so the provider stops retrying.

Operational hygiene

This is the first step in every inbound pipeline we build.