How to verify webhook signatures (and the mistakes that quietly break it)
A webhook is an HTTP POST that arrives at a public URL on your server. That is the whole design, and it is also the problem. Anyone who learns the URL can send a request to it, and your handler has no built-in way to tell a real event from curl. If you act on an unverified webhook, you are letting the open internet trigger your code. Someone can forge a "payment succeeded" event and mark an order paid, or replay a real one to ship the same thing twice.
Signature verification is how you close that hole. The sender signs each payload with a secret only the two of you know, and you recompute the signature on your side and check that it matches. Get it right and a forged request is rejected before your logic ever runs. Get it subtly wrong, which is easy, and you ship a check that looks like it works and verifies nothing.
The mechanism: HMAC over the raw body
Almost every provider uses the same primitive: an HMAC. When you register an endpoint you are given a signing secret. For each delivery the sender computes HMAC-SHA256(secret, body), puts the result in a header, and sends both. You compute the same HMAC over the body you received, with the same secret, and compare. Because HMAC depends on the secret, an attacker who does not have it cannot produce a matching value, even though they can see the algorithm.
The detail that trips people is the word body. HMAC is computed over the exact bytes the sender hashed. Not your framework's parsed-then-reserialized JSON, not a pretty-printed version, the raw bytes on the wire. This is the single most common reason verification fails for a request that is actually genuine, and it is worth holding onto before we look at the specific schemes.
How Stripe does it
Stripe sends a Stripe-Signature header that is a comma-separated list of key/value pairs, like t=1700000000,v1=5257a869.... The t value is the Unix timestamp when Stripe generated the signature, and v1 is the HMAC-SHA256 in the current scheme. The twist is what gets signed: not the raw body alone, but the string timestamp + "." + payload. So you parse the timestamp out of the header, rebuild that exact concatenation, HMAC it with your endpoint secret, and compare against v1.
Folding the timestamp into the signed string is what makes Stripe's scheme replay-resistant. Stripe's official SDK helper, constructEvent in Node or construct_event in Python, also enforces a default tolerance of five minutes between that timestamp and now, and does the comparison in constant time. Their own guidance is blunt about this: use the SDK helper rather than hand-rolling the HMAC, because the helper gets the header parsing, the tolerance window, and the constant-time compare right for you.
How GitHub does it
GitHub signs the raw request body directly and puts the result in X-Hub-Signature-256, prefixed with sha256=. You compute HMAC-SHA256(secret, raw_body), prepend the same sha256= string, and compare. GitHub's documentation is specific about two things that bite people: use the byte-for-byte payload GitHub sent, with no added whitespace or re-encoding, and compare with a constant-time function rather than a plain equality check. Their Python example uses hmac.compare_digest; their Node example uses crypto.timingSafeEqual.
The shape is the same as Stripe minus the timestamp prefix, which is the pattern you will see almost everywhere once you know to look for it: a header, a known algorithm, a shared secret, and the raw body.
The four mistakes that quietly break it
- Verifying the parsed body instead of the raw bytes. Most web frameworks helpfully parse the JSON body before your handler runs. Re-serializing that object gives you different bytes than the sender hashed (key order, spacing, Unicode escaping all differ), so the HMAC never matches a legitimate request. You have to capture the raw body before any middleware touches it. In Express that means a raw body parser on the webhook route; in many frameworks it is a specific "raw" or "bytes" accessor. This is the failure that sends people in circles, because the request is real and the secret is right.
- Comparing with
==instead of a constant-time check. A naive string compare returns as soon as two characters differ, so how long it takes leaks how many leading characters were correct. That is enough to recover a valid signature one byte at a time. Usehmac.compare_digest,crypto.timingSafeEqual, or your language's equivalent, every time. - No replay protection. A valid signature proves the payload is authentic, not that it is fresh. An attacker who captures one real delivery can send it again, and the signature still verifies. Stripe's timestamp tolerance is one half of the defense. The other half is idempotency: record each event ID you process and ignore duplicates, so re-delivering "charge succeeded" cannot bill twice. You want both, because providers legitimately retry on timeouts too.
- Returning 200 before you verify, or skipping verification "for now." Some handlers acknowledge the request first to stop the provider retrying, then process, and the verify step quietly gets skipped on some paths. Verify first. Reject unsigned or badly-signed requests with a 400 before any business logic, logging, or database write runs.
A minimal correct check
Stripped of any framework, the generic version is short. Read the raw body as bytes, compute the HMAC, compare in constant time:
import hmac, hashlib
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
For Stripe, the only change is signing f"{timestamp}.{raw_body.decode()}" instead of raw_body, plus the timestamp tolerance check. For most providers, reach for their official SDK helper first and only hand-roll this when there is no helper, because the helpers bake in the constant-time compare and tolerance you would otherwise have to remember.
When the signature still will not match
Sooner or later verification fails on a request you are sure is legitimate, and the only way to settle it is to compare the exact bytes the sender sent against the exact bytes you hashed. That means seeing the raw payload, the precise header value, and the content type, byte for byte, not your framework's cleaned-up rendering of them. Most of the time the difference is exactly the raw-body mistake above: a stray re-encoding, a charset, a trailing newline. Once you can see both sides, the mismatch is usually obvious in under a minute. If you are still standing up the endpoint and deciding which provider to wire in, I went through the options in webhook debugging tools worth using in 2026.
None of this is exotic cryptography. It is one shared secret, one hash function, and the discipline to hash the bytes that actually arrived. Do that, compare them safely, and reject anything that does not match, and your webhook endpoint stops being an open door.