Skip to main content
Back to blog
Engineering·May 11, 2026·6 min read

The one line of code that prevents fake payment notifications (and the seven that make it bulletproof)

An attacker who knows your webhook URL can post fake payment.confirmed events and convince your application to provision unpaid access. The defense is constant-time HMAC verification — one line of crypto, easy to get subtly wrong. Here's the canonical implementation and the four failure modes we've seen in incident reviews.

OT
OpenSettle Team· Platform engineering

Your webhook URL is publicly addressable. It has to be — your billing platform needs to reach it from the internet. So does anyone else who finds it. Webhook URLs end up in source maps, in browser DevTools, in Git history that escaped a stale .gitignore, in your support team's tickets. They are not secret. They cannot be secret.

What stops an attacker from posting a fake payment.confirmed event to your webhook and tricking your application into provisioning unpaid access? Exactly one thing: signature verification on receipt. If you skip it, every webhook your application receives is an opportunity for an attacker to lie. If you do it wrong, same outcome.

The one line

The platform signs every webhook with an HMAC-SHA256 over the raw request body, using a per-merchant secret. The signature comes in a header. Your handler computes the same HMAC over the body it received, with the same secret, and compares.

app/api/webhooks/opensettle/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(req: Request) {
  const sig = req.headers.get("opensettle-signature") ?? "";
  const body = await req.text();
  const expected = createHmac("sha256", process.env.OPENSETTLE_WEBHOOK_SECRET!)
    .update(body)
    .digest("hex");

  // The one line.
  if (!timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) {
    return new Response("invalid signature", { status: 401 });
  }

  const event = JSON.parse(body);
  // ... handle event
  return new Response("ok");
}

Three things matter. Constant-time comparison (timingSafeEqual, not ===). The raw body (not the parsed JSON). The right secret (per-merchant, not a global one). Get any of those wrong and the verification is theatrical rather than load-bearing.

Why constant-time

A naive string equality check returns false as soon as it finds the first mismatching byte. An attacker who can measure response time across thousands of requests can use that timing difference to discover the signature byte-by-byte. This is not theoretical — it's a published attack against early webhook implementations across the payments industry.

timingSafeEqual compares every byte regardless of where the mismatch is, so the response time leaks no information about what the correct signature looks like. The CPU cost is identical for any input. This is the cheap, easy, correct way to do it. Don't roll your own.

Why the raw body

If you parse the body and then re-serialize it for HMAC, you've already lost. JSON.stringify can produce a different byte string than what the platform signed: different key order, different whitespace, different float precision. The signature won't match even on valid events, and worse, you'll be tempted to "loosen" the verification to make tests pass.

Read the raw body once, verify against it, then parse for use. Many frameworks (Express, Fastify, Next.js Pages router) helpfully pre-parse the body for you, which is exactly the wrong thing here. The App Router's request.text() gives you the raw bytes. Use it.

Why per-merchant secrets

A common mistake: hold a single global webhook secret for all your tenants. If one tenant leaks it, every tenant's webhooks become forgeable. Worse, you have no path to rotate it without coordinating with every tenant simultaneously.

Per-tenant secrets, rotatable independently. The merchant retrieves the current secret from their dashboard at /app/webhooks. Your handler looks up the secret for the tenant the event is addressed to (the event's body identifies the workspace) and verifies with that one. Rotation is one merchant at a time.

Four ways webhook verification goes wrong

  • Engineer parses the body for ergonomics, re-serializes for HMAC, signature mismatches on every event. Engineer disables verification "temporarily." Two weeks later an attacker discovers the open webhook and provisions thousands of dollars in unpaid usage before anyone notices.
  • Engineer uses === instead of timingSafeEqual. Audit finds it years later. Nothing bad happened yet but it's a published attack class.
  • Engineer hard-codes the webhook secret in source for a quick test. Commit reaches a public mirror. Rotation is required.
  • Engineer puts the secret in NEXT_PUBLIC_OPENSETTLE_WEBHOOK_SECRET because that's the prefix all the other env vars used. Secret leaks into client bundle. Rotation is required.

Two of those four are operational discipline (don't commit secrets, don't prefix server-only vars as public). The other two are the crypto itself. Get the seven-line snippet above right, and you've defended against the entire attack surface. Get any line wrong, and you haven't.

What to read next

If you're integrating from scratch: the webhooks documentation has the per-event payload shapes, the retry policy, and the signature spec. If you're hardening an existing integration: this snippet replaces whatever's in your handler today. If you're operating in production and want to think about what else could go wrong with on-chain billing webhooks specifically: Reorg-safe payment confirmation covers the state-transition events you'll want to handle in addition to the simple confirmed case.