Engineering · 7 min read
Verify webhooks properly: HMAC, replay protection and idempotency
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
- Sign the raw body. Compute the HMAC over the exact bytes received. If you
JSON.parsefirst and re-serialise, whitespace or key-order changes will break verification, or worse, let a tampered body through a lenient comparison. - Compare in constant time. A plain
===on hex strings can leak how many leading characters matched.crypto.subtle.verifydoes the comparison safely. - Bind a timestamp into the signature and enforce a window. Signing
timestamp.bodyand rejecting requests older than five minutes stops an attacker replaying a captured valid request later. - 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:
- valid signature accepted
- tampered body rejected
- wrong signature rejected
- malformed hex rejected
- stale timestamp (400 s old) rejected
- wrong secret rejected
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
- Store the secret as a Worker secret (
wrangler secret put), never in the repo orvars. - Support two active secrets during rotation, so you can change one without dropping events.
- Return
401with an empty body on failure, and log the reason internally only.
This is the first step in every inbound pipeline we build.