Verifying webhook signatures correctly

A payment webhook endpoint is a public URL. Signature verification is what stops it from being a public URL anyone can POST fake "payment succeeded" events to — but the mechanics have more sharp edges than they first appear.

Intermediate

3 min read

Why this can't be skipped

A webhook endpoint has to be reachable from the public internet — that's the entire point, it's how a third-party gateway calls it. Without verifying the request actually came from the gateway, that same public reachability means anyone who discovers the URL could POST a fabricated "transaction succeeded" payload and have a system believe an order was paid for when it wasn't. Signature verification is what closes that gap.

The mechanism: HMAC, and a shared secret

Gateway computes:  signature = HMAC-SHA256(secret, some_representation_of_the_payload)
Gateway sends:      the payload, plus that signature, in the request
Merchant computes:  the same HMAC, using the same secret, over what it
                     believes is the same representation
Merchant compares:  its own computed signature against the one sent

If both sides used the same secret and hashed the same bytes, the signatures match — and since HMAC is a keyed hash, only someone who knows the shared secret (a merchant, and the gateway) could have produced a matching signature. This is symmetric-key authentication, not encryption — the payload itself is often still plain, readable JSON; the signature only proves who sent it, not that it's hidden from anyone reading network traffic.

Sharp edge 1: "the same representation" is not always the raw request body

It's tempting to assume the signature always covers "the JSON body, verbatim." In practice, different gateways — and even different event types from the same gateway — can compute the signature over different things:

Some events: signature travels as a query parameter, computed over a
             specific, fixed-order concatenation of individual fields
             pulled out of the payload (not the raw body at all)

Other events (from the same gateway): signature travels inside the
             JSON body itself, computed over a different, smaller
             string built from just a couple of the event's own fields

The only way to get this right is reading that specific gateway's documentation for that specific event type — assuming "it's always the raw body" is a common, and completely reasonable-looking, mistake that produces a verification function that silently never matches.

Sharp edge 2: comparing signatures with === is itself a bug

// Wrong: vulnerable to a timing attack
if (computedSignature === receivedSignature) { ... }
 
// Right: constant-time comparison
import { timingSafeEqual } from "crypto";
if (computedSignature.length === receivedSignature.length &&
    timingSafeEqual(Buffer.from(computedSignature), Buffer.from(receivedSignature))) {
  ...
}

A naive string comparison (===, or most languages' default equality) returns as soon as it finds the first mismatched character — which means the time it takes to fail leaks information about how many leading characters were correct. Given enough attempts, that timing difference is (in principle) enough to reconstruct a valid signature byte by byte. timingSafeEqual-style functions take the same amount of time regardless of where the mismatch is, closing that channel. The length check has to happen before the constant-time comparison, since most timingSafeEqual implementations throw (or behave undefined) on mismatched-length buffers rather than safely returning false.

Sharp edge 3: signature verification has to happen before anything else runs

The verification step belongs as the very first thing an endpoint does — commonly implemented as a guard/middleware that runs ahead of any handler logic — precisely so an invalid signature is rejected before the payload is ever parsed into a domain object, looked up in a database, or acted on in any way.

The practical checklist

  • Confirm, per event type, exactly what bytes the signature actually covers (raw body? specific fields, in what order? query param or body field?).
  • Use a constant-time comparison function, always checking length first.
  • Reject with an error status (not silently ignore) before any other processing happens.
  • Never trust an unsigned or incorrectly-signed request, no matter how "obviously legitimate" its payload looks.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. A payment webhook endpoint has no signature verification at all. What's the concrete risk?

2. A developer writes signature verification assuming it always covers the raw JSON body, for every event type from every gateway. What's the risk?

3. Why is comparing two signature strings with a plain === (or default equality) considered a security bug, not just a style nitpick?

4. Where should signature verification happen relative to parsing the webhook payload into a domain object?