← Back to Guides
8 min readIntermediate
Share

Verifying Webhooks Safely

Anyone can POST to your webhook URL. HMAC signature verification, timing-safe comparison, replay protection, and the raw-body gotcha that breaks all three in Next.js.

Verifying Webhooks Safely

A webhook endpoint is a URL that does something important — mark an order paid, provision an account, deploy a build — triggered by a POST request from the outside internet. The uncomfortable part: that URL is public. Nothing stops anyone from finding it and sending their own POST with a fabricated payload. {"event": "payment.succeeded", "amount": 0} costs an attacker one curl command.

The provider's payload includes a signature for exactly this reason. If you don't check it, the signature might as well not exist — you've built a webhook handler that trusts the network.

The raw-body gotcha

Signature verification hashes the exact bytes the provider sent, using a secret only you and the provider know. If what you hash differs from what they hashed by even one byte — different whitespace, re-serialized JSON, a trailing newline — verification fails on legitimate requests.

This is where it goes wrong in a Next.js route handler. Calling request.json() parses the body, and re-stringifying it later (JSON.stringify(parsed)) does not reproduce the original bytes — key order, spacing, and number formatting can all shift. You have to read the raw text before anything touches it as JSON:

// app/api/webhooks/billing/route.ts
export async function POST(req: Request) {
  const rawBody = await req.text();       // exact bytes, unparsed
  const signature = req.headers.get("x-webhook-signature");

  if (!signature || !isValidSignature(rawBody, signature)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody);       // safe to parse *after* verifying
  // ... handle event
}

Note the order: read raw text, verify, then parse. Reversing steps two and three is the single most common webhook bug — it looks identical to a working handler until the day someone forges a request.

HMAC verification

Most providers (Stripe, GitHub, Shopify, and anything modeled on them) sign with HMAC-SHA256: the provider computes HMAC(secret, payload) and sends it in a header; you recompute the same hash and compare.

import { createHmac, timingSafeEqual } from "node:crypto";

function isValidSignature(rawBody: string, signatureHeader: string): boolean {
  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(rawBody, "utf8")
    .digest("hex");

  const expectedBuf = Buffer.from(expected, "hex");
  const actualBuf = Buffer.from(signatureHeader, "hex");

  // Different lengths would throw inside timingSafeEqual — check first.
  if (expectedBuf.length !== actualBuf.length) return false;

  return timingSafeEqual(expectedBuf, actualBuf);
}

Use timingSafeEqual, not ===. String comparison with === exits on the first mismatched character, so the time it takes leaks how many leading bytes were correct. That's a real, exploitable side channel over enough requests — a byte-at-a-time signature forgery. timingSafeEqual always compares the full length, so the timing reveals nothing. This is the kind of thing an AI assistant will happily "simplify" to === if you ask it to clean the function up — the code still looks correct, the tests still pass, and the vulnerability is invisible in a diff.

Some providers (GitHub) prefix the header with the algorithm name, e.g. sha256=<hex> — strip that prefix before comparing.

Replay protection

A valid signature proves the payload came from the provider at some point. It doesn't prove it's fresh. If an attacker captures a legitimate webhook request (a compromised proxy, a logged request, a browser extension on your ops dashboard), they can replay it verbatim and it'll pass signature verification every time.

Providers that care about this include a timestamp in the signed payload. Verify it's recent before trusting the signature:

const MAX_CLOCK_SKEW_SECONDS = 5 * 60;

function isTimestampFresh(timestampHeader: string): boolean {
  const sentAt = Number(timestampHeader);
  if (!Number.isFinite(sentAt)) return false;
  const ageSeconds = Math.abs(Date.now() / 1000 - sentAt);
  return ageSeconds <= MAX_CLOCK_SKEW_SECONDS;
}

Five minutes is generous enough to absorb clock drift and network retries without leaving a meaningfully large replay window. If the provider signs the timestamp together with the body (many do — check their docs for the exact string they hash), verify the timestamp as part of the signature, not as a separate check, or an attacker can pair a valid old signature with a forged fresh timestamp.

Idempotency: webhooks arrive more than once

This isn't a security issue, but it'll bite you just as hard. Providers guarantee at-least-once delivery — a timeout on their end, a 500 from a deploy mid-request, or their own retry logic can all cause the same event to hit your endpoint two or three times. If your handler charges a card or sends an email on every call, you'll double-charge or double-send.

The fix is to record which event IDs you've already processed and short-circuit the rest:

export async function POST(req: Request) {
  const rawBody = await req.text();
  const signature = req.headers.get("x-webhook-signature");
  if (!signature || !isValidSignature(rawBody, signature)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody);

  // Insert-or-skip on a unique event id — the database is the source of
  // truth, not an in-memory Set (which resets on every cold start/deploy).
  const inserted = await db.processedWebhookEvent.createIfNotExists({
    id: event.id,
  });
  if (!inserted) {
    return new Response("Already processed", { status: 200 }); // not an error
  }

  await handleEvent(event);
  return new Response("OK", { status: 200 });
}

Return 200 for a duplicate, not an error — from the provider's perspective the delivery succeeded, and returning a 4xx/5xx just triggers another retry of an event you've already handled.

What not to do

  • Don't trust a source or provider field inside the payload itself. Anyone can put "source": "stripe" in a forged JSON body. The signature is the only thing that proves origin; fields inside the payload are just data.
  • Don't verify signatures conditionally ("skip verification in dev to make testing easier" and forgetting to remove it, or gating on an env var that defaults to false the wrong way). Verify the same way in every environment; use the provider's test-mode secret in dev instead of disabling the check.
  • Don't log the raw payload or signature at info level. Both are exactly what an attacker needs to replay or forge a request; if you must log for debugging, redact the signature header and gate it behind a debug flag that's off by default.
  • Don't do the comparison with ===. Covered above, but worth repeating: it's the one-line change that looks like a harmless cleanup.

Testing locally

You don't need the provider's real infrastructure to test this. Compute a valid signature yourself with the same function your handler uses, POST it with curl, and confirm the handler accepts it — then flip one byte in the body and confirm it's rejected:

BODY='{"id":"evt_test_1","type":"payment.succeeded","amount":4200}'
SECRET="whsec_test_local"
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -X POST http://localhost:3000/api/webhooks/billing \
  -H "Content-Type: application/json" \
  -H "x-webhook-signature: $SIG" \
  -d "$BODY"

If your handler is rejecting a request you believe is genuinely signed, the raw-body gotcha above is the first thing to check — confirm you're hashing the exact same bytes that hit the wire, not a re-serialized version of them.

Related: Security for vibecoded apps · Working with APIs · Environment variables and secrets, done right

Get the good stuff

New tools and posts, occasionally. No spam.