Skip to content
crispforms

Verifying webhook signatures without getting it subtly wrong

Five mistakes that make signature verification look like it is working when it isn't — and why the last one is the dangerous one.

· 5 min read · 1,069 words

Share

A webhook endpoint is a URL on the public internet that changes your data. Verifying the signature is the entire thing standing between that and anybody who finds the URL. It is not difficult, but there are several ways to write code that appears to verify and does not, and they share a nasty property: they pass every test you are likely to write.

1. Signing the parsed body instead of the raw bytes

The signature covers the exact bytes that were sent. If your framework parses JSON before you see it, and you re-serialise the object to check the signature, you are checking a different set of bytes.

Key order, whitespace, unicode escaping, and how your language formats numbers can all differ from what the sender produced. A float that arrived as 1.0 and re-serialises as 1 is enough.

The reason this one is dangerous is that it usually works. Two systems with the same JSON conventions agree most of the time. It fails intermittently, for one sender, six months later, and the resulting bug report is unfalsifiable.

Capture the raw body before anything parses it, and verify against that.

It usually works. That is exactly what makes it dangerous — it fails for one sender, six months later.

2. Comparing with ===

String comparison short-circuits on the first differing character, so how long the comparison takes reveals how much of the prefix was correct. Over enough requests that is sufficient to reconstruct a valid signature, one character at a time.

Whether this is practically exploitable over the internet is genuinely debatable — network jitter swamps the signal, and you would need a great many requests. It is also completely free to avoid, and the argument is not one you want to be having during an incident.

Use a constant-time comparison. Every language has one; in Node it is crypto.timingSafeEqual, and it throws when the lengths differ, so compare lengths first and return false rather than letting the throw escape.

3. Not checking the timestamp

A correctly signed request stays correctly signed forever. If you do not reject old ones, anybody who ever captures a request can replay it indefinitely — and requests get captured in ordinary, non-malicious ways: proxy logs, an error tracker that records request bodies, a screenshot in a support ticket, a misconfigured CDN.

The timestamp has to be part of the signed payload, or it can simply be edited. Sign id, timestamp and body together; reject anything more than about five minutes old.

Five minutes is the conventional tolerance and it exists because clocks drift. If you find yourself widening it, fix the clock instead.

4. Trusting the signature to imply idempotency

A verified request is authentic. It is not necessarily new. Providers retry, and a retry after a timeout is indistinguishable from the original except by its id.

Store the event id with a unique constraint and write it before you process anything. If the insert fails, you have seen it: return a 200 and do nothing. Doing this after processing is a race, and the window is exactly the interval during which a retry is most likely.

This is not a security mistake, but it lives in the same code and it is the one most likely to actually bite you.

5. Failing open

The one that matters most, and the one that appears in real codebases most often.

If the signing secret is not configured, refuse everything. Do not skip verification. It is remarkably easy to write the opposite by accident — an if statement that checks a secret exists before validating, with no else — and the result is an endpoint that quietly accepts anything the moment an environment variable goes missing.

A deploy that loses a secret should break the integration loudly. Deliveries will queue up at the provider and get retried; that is a bad afternoon. The alternative is an open door that nothing alerts on, because from the outside everything looks like it is working.

A short checklist

Before you ship a webhook receiver:

  • Verify against the raw request bytes, not a re-serialised object.
  • Compare in constant time, after checking the lengths match.
  • Sign and check a timestamp; reject anything older than five minutes.
  • Persist the event id under a unique constraint before processing.
  • Refuse every request when the secret is missing, and make that loud.
  • Return 2xx for anything you have handled or deliberately ignored, and 5xx only when you want a retry.

What your status code actually means to the sender

The response code is not a formality; it is an instruction to the other system's retry logic, and getting it backwards causes two distinct and equally annoying failures.

Return 5xx for anything transient — your database was briefly unavailable, a downstream service timed out. The sender will try again with a growing gap, which is exactly right.

Return 2xx for anything you understood and deliberately did nothing with. An event type you do not handle is not an error. Returning a 4xx for it fills the sender's failure dashboard with noise, and on some providers enough failures will disable your endpoint entirely.

Return 4xx only when retrying genuinely cannot help: the signature was wrong, the payload was not JSON, the event refers to something that has never existed. Those should not be retried, and saying so is more useful than a generic 500.

The failure worth naming: returning 200 from inside a catch block so the provider stops complaining. That converts a visible error into silent data loss, and it will be months before anyone notices the records that never arrived.

Process asynchronously, respond immediately

Providers time out, usually somewhere between five and thirty seconds, and a timeout is treated as a failure and retried. If your handler verifies the signature and then does the actual work inline, every slow dependency becomes a duplicate delivery.

The shape that avoids this: verify, persist the raw event, return 200. Then do the work from a queue. The endpoint's job is to decide whether the request is genuine and to make sure it is not lost — nothing more.

This also gives you replay for free. When the work fails for a reason that is nobody's fault — a third-party outage, a bug you have since fixed — the raw event is still sitting there and can be run again without asking the provider to resend anything.

webhookssecurity
Share

Build a form that people finish

Free, with the per-question drop-off analytics this piece keeps going on about.

Start building free