Webhooks
Why your HMAC webhook signature check is wrong (and how to fix it)
Posted July 15, 2026 · 6 min read
I've audited production webhook handlers at three companies. Every single one had at least one of four bugs in their HMAC signature verification. These aren't theoretical — they let attackers forge events, replay old ones, or trigger expensive recomputation.
Most blog posts show you code like:
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
if (signature === expected) { /* ok */ }This has bugs. Here's each one:
Bug 1: Plain === comparison
String equality in JS short-circuits at the first mismatch. An attacker can measure response time to determine how many bytes matched — that's enough to recover the signature byte-by-byte. Use crypto.timingSafeEqual instead:
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
)) {
return new Response('bad sig', { status: 400 });
}Bug 2: timingSafeEqual with mismatched lengths
timingSafeEqual throws if the buffers are different lengths. An attacker can send a 1-byte signature and use the error response time to confirm length. Always compare lengths FIRST:
const received = signature.toLowerCase();
if (received.length !== expected.length) {
return new Response('bad sig', { status: 400 });
}
if (!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
return new Response('bad sig', { status: 400 });
}Bug 3: Computing HMAC on parsed JSON
Most examples do req.json() first, then HMAC the parsed object. JSON.stringify is non-deterministic across JSON shapes (key order, whitespace). The provider signed the RAW bytes of the body, not your parsed version. Always read the body as text FIRST, HMAC, then parse:
const rawBody = await req.text(); // ← read first
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // ← HMAC the raw body
.digest('hex');
// THEN validate signature
// THEN parse JSON
const event = JSON.parse(rawBody);Bug 4: No replay protection
If your provider sends a timestamp header (Stripe does, GitHub does), include it in the signed payload and reject anything older than 5 minutes. Otherwise an attacker who captures one legitimate webhook can replay it forever.
const timestamp = req.headers.get('x-signature-timestamp');
const FIVE_MINUTES = 5 * 60;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > FIVE_MINUTES) {
return new Response('too old', { status: 400 });
}
// Now include timestamp in the signed payload:
const signedPayload = `${timestamp}.${rawBody}`;The complete template (works for any HMAC provider)
import crypto from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function POST(req: NextRequest) {
const secret = process.env.WEBHOOK_SECRET!;
const signature = req.headers.get('x-signature');
const timestamp = req.headers.get('x-signature-timestamp');
if (!signature || !timestamp) {
return NextResponse.json({ error: 'missing headers' }, { status: 400 });
}
// Replay protection
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return NextResponse.json({ error: 'replay too old' }, { status: 400 });
}
const rawBody = await req.text();
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = signature.toLowerCase();
// Length check before timingSafeEqual
if (received.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
return NextResponse.json({ error: 'bad signature' }, { status: 400 });
}
// NOW parse JSON
const event = JSON.parse(rawBody);
// Process the event (always return 200 on partial failure to stop retries)
try {
await handleEvent(event);
} catch (err) {
console.error('event handler failed:', err);
// Return 200 — don't trigger infinite retries
}
return NextResponse.json({ received: true });
}Testing your signature handler
Send a signed request from your terminal using openssl to compute the HMAC, just like the provider would:
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.*= //') curl -X POST \ -H "x-signature: $SIG" \ -H "content-type: application/json" \ -d "$BODY" \ https://your-domain.com/api/webhook
The full pattern (with Supabase insert + idempotency check + retry logging) is in Forge Stack. Real production code, not example scaffolding.
Tags: #webhooks #hmac #stripe #security #nodejs