Skip to main content
MemCyber
Web App · 7 min read

Webhook Signature Validation: The Five Bugs We Find Most

Webhook handlers look simple and therefore get written carelessly. A quick tour of the signature-validation bugs we find over and over again across fintech and SaaS engagements.

By Atilla Mammadli

Webhook handlers are where money gets moved and state gets changed in response to a message from “somewhere.” They deserve the same scrutiny as your auth middleware — they are auth middleware for server-to-server traffic — but they rarely get it. These are the five bugs we find most often during engagements.

1. Timing-variant HMAC comparison

The canonical webhook pattern:

const expected = crypto
  .createHmac('sha256', WEBHOOK_SECRET)
  .update(rawBody)
  .digest('hex');
if (req.headers['x-signature'] !== expected) return res.status(401).end();

The !== comparison leaks timing information. Over enough requests, an attacker can probe the first few bytes of the expected signature by measuring response times, then extend the probe one byte at a time. Empirically this is hard on the open internet, but “hard” is not “infeasible” — and the fix is a one-line change.

Defense: Use crypto.timingSafeEqual (or your language’s equivalent). Convert both inputs to fixed-length buffers first and check length before comparing to avoid the common pitfall of timingSafeEqual throwing on length mismatch.

2. Canonicalization drift

The server computes the HMAC over one serialization of the body. The client computes it over another. Both are valid JSON, but whitespace, key ordering, or numeric formatting differs. Everyone’s code works in tests because they round-trip through the same stringifier. In production, under load or with a different proxy in the path, it breaks.

Worse: if your verification code re-serializes the parsed body before hashing, an attacker who can inject a semantically-equivalent but textually-different body (e.g. by adding a duplicate key that your parser silently de-duplicates) can forge signatures.

Defense: Hash the raw bytes of the request body, not a re-serialized form. Configure your framework to expose the raw body alongside the parsed one (Express needs express.raw() before express.json() on this route; Fastify has rawBody: true; Next.js needs bodyParser: false in the handler config).

3. No timestamp validation

A valid signed webhook payload is, by design, replayable. If you do not verify a timestamp, any captured webhook can be replayed against you forever. For payment webhooks this means a refunded transaction can be re-processed; for subscription webhooks, a cancelled subscription can be un-cancelled.

Defense: Require a timestamp claim in the signed payload (both Stripe and Shopify include this). Reject requests older than a small window (5 minutes is standard). Cache processed event_ids for at least twice that window to close the replay gap at the boundary.

4. Signature not bound to the URL path

We frequently see handlers where the HMAC covers only the request body — not the path. Two webhooks on the same service, signed with the same secret, are interchangeable. If the attacker can trigger a legitimate webhook on endpoint A, they can replay its signed body to endpoint B, where the same secret validates but a different state machine runs.

In one engagement this let an attacker repurpose a subscription.updated webhook into a payout.initiated call by sending the same body to the payout endpoint — which had different business logic but the same validation.

Defense: Include the path (and ideally the full method + path + timestamp) in the signed payload. At the very least, use a different secret per endpoint if the provider supports it.

5. Trust boundaries at the wrong layer

The subtlest bug: validation happens correctly in the webhook handler, but the handler hands the parsed payload to an internal queue or service that re-reads the payload without re-validating. An attacker who finds any other path into that internal queue — a forgotten debug endpoint, a lateral move through a dependency, a misconfigured Lambda — bypasses all the careful webhook validation because the downstream code trusts the queue.

This one feels architectural rather than a “webhook bug,” but it is where the real breach happens. The webhook handler is not the trust boundary. The data’s origin inside the payload is.

Defense: Carry the signature (or a fingerprint of the validated payload) through your internal pipeline, and have downstream consumers re-validate against the original secret. Alternatively, sign the internal message with a key only the webhook handler holds, and require that signature downstream.

Takeaway

Every webhook handler looks trivial. That is why every webhook handler has bugs. When reviewing your own, walk through each of the five failure modes above — and then ask what happens if the payload is replayed, resent to a sibling endpoint, or intercepted before it reaches your handler. If any answer is “bad things,” you have work to do.

Tags webhookssignaturesHMACreplay

Continue reading

Recognize this pattern in your own stack?

We run targeted reviews against exactly these classes of bug. One email away.

Request Assessment