← All posts

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