◀ Knowledge hub

04, Identity & secrets

Verifying webhooks so you can trust them

codeAmani Labs Engineering
Cinematic still for Verifying webhooks so you can trust them

A webhook is an unauthenticated request until you prove otherwise

Every webhook endpoint is a public URL that accepts a POST. A payment provider, an auth service, and an attacker who found the URL all reach it the same way. If you act on the body without verifying who sent it, you have built an endpoint where anyone can claim a payment succeeded or an account should be deleted.

Verify the signature against the raw body

Senders sign the request with a shared secret and put the signature in a header. You recompute the signature over the exact bytes you received and compare. The detail that trips people up: you must verify against the raw request body, before any JSON parsing, because parsing and re serializing can change a byte and break the signature.

export async function POST(req: Request) {
  const raw = await req.text(); // raw body, not parsed
  const signature = req.headers.get("webhook-signature") ?? "";

  const expected = hmacSha256(process.env.WEBHOOK_SECRET!, raw);
  if (!timingSafeEqual(signature, expected)) {
    return new Response("Invalid signature", { status: 400 });
  }

  const event = JSON.parse(raw); // safe to parse now
  // act on the verified event
}

Use a constant time comparison

Compare the signatures with a timing safe equality function, not the ordinary equals operator. A normal comparison returns faster when the first byte differs, and that timing difference is enough for a patient attacker to guess a signature one byte at a time. Constant time comparison removes the signal.

Make the handler idempotent

Senders retry. A network blip means the same event can arrive twice, so processing it must be safe to repeat. Key the work on the event id and record which ids you have handled, so the second delivery of a "payment succeeded" event does not credit the account twice. This is the same idempotency discipline that the payments layer relies on (see the idempotency guide).

The rule

Treat every webhook as hostile until the signature checks out, compare in constant time, and assume it will be delivered more than once. Those three habits turn a public POST endpoint from a liability into a dependable integration.

Qualified conversation

Have a build to de-risk? Let's talk.

Tell us what you are building. We respond within two business days.