Verifying a Webhook Signature: Raw Body, Constant-Time Comparison and the Replay Window (2026)
You are receiving webhooks at a public URL. Anyone who learns that URL can post to it, and your handler creates records.
Compute an HMAC over the raw request body using the shared secret, compare it to the signature header in constant time, and reject anything that does not match — before the payload is used for anything, including logging it as a legitimate event. Verify against the exact bytes received, because parsing and re-serialising JSON changes them and breaks the comparison.
The raw body is the part that matters
Signatures are computed over bytes, not over meaning. If a middleware parses JSON before your handler sees it, re-encoding produces different bytes — key order and whitespace are not preserved — and verification fails intermittently for reasons that look random. Capture the raw body before any decoding.
Why comparison must be constant-time
A normal string comparison exits at the first differing byte, so the time it takes reveals how many leading bytes were correct. That is enough to reconstruct a valid signature byte by byte given enough attempts. Every language ships a constant-time comparison for this reason.
Replay protection
A valid signature stays valid forever unless something binds it to a moment. Senders include a timestamp in the signed payload; receivers reject anything outside a short window, commonly five minutes. Without it, a captured request can be replayed indefinitely.
Rotating the secret without downtime
Accept two secrets during rotation. Add the new one, verify against either, switch the sender, then remove the old one. Rotating in a single step guarantees dropped events for as long as the two sides disagree.
The raw body is the only thing you can sign
Signatures are computed over bytes, not over meaning. If a middleware parses JSON before your handler sees it, re-encoding produces different bytes — key order is not preserved, whitespace is normalised, unicode escapes are rewritten and large numbers may lose precision — and verification fails for reasons that look random. Capture the raw body before any decoding, keep it as a string rather than a parsed structure, and verify against that. Most frameworks make the raw body available but stop doing so once something has read the parsed form, which is why the capture has to happen early in the stack rather than in the handler.
Why comparison must be constant-time
A normal string comparison exits at the first differing byte, so the time it takes reveals how many leading bytes were correct. Given enough attempts an attacker reconstructs a valid signature one byte at a time, without ever knowing the secret. Every language ships a constant-time comparison for exactly this reason, and using it costs nothing. The mistake is not usually ignorance of the risk; it is that the comparison gets written with the ordinary operator during a debugging session and never changed back.
What the signed payload should contain
Signing the body alone leaves the request replayable forever, so senders sign a timestamp alongside it, usually as a concatenation of the timestamp, a separator and the body. The receiver reconstructs that same string before computing its HMAC. Anything the receiver relies on for a security decision has to be inside the signed string: a timestamp sent in an unsigned header is a timestamp an attacker rewrites. The same applies to any event id used for deduplication.
The boundary case: how old is too old
Five minutes is the standard tolerance. Below roughly one minute, ordinary clock drift between two servers starts rejecting legitimate deliveries — and drift of several seconds between machines that are not running NTP is routine, so a tight window produces intermittent failures that look like a signing bug. Above fifteen minutes the replay surface widens without buying anything, since no legitimate sender is that late. The window is a security control and a reliability control at the same time, which is why both bounds matter.
Clock drift is the usual cause of intermittent failure
If verification fails for every request, the secret or the signed string is wrong. If it fails for a small percentage, suspect the clock or the body. Log the difference between the payload timestamp and your own on every rejection: a consistent offset in one direction is a clock that needs correcting, while a scatter around zero with occasional outliers is normal network delay. Widening the tolerance to make the failures stop hides a clock problem and lengthens the replay window at the same time.
Verify before you acknowledge, and before you log
Returning 200 tells the sender the event was accepted, and an unverified payload has not been accepted. Verify first, acknowledge second, process asynchronously third. Verification also has to precede treating the payload as a real event anywhere else, including writing it to a log as legitimate: an attacker who can inject convincing entries into your event log has achieved something even if no record was created.
Rotating the secret without dropping deliveries
Accept two secrets during rotation. Add the new one to the receiver, verify against either, switch the sender, confirm traffic is arriving under the new secret, then remove the old one. Rotating in a single step guarantees dropped events for as long as the two sides disagree, which includes the time it takes to notice. The receiver should log which secret verified each request, otherwise the final step is taken blind and the old secret gets removed while some sender is still using it.
What to do with a delivery that fails verification
Return 4xx and do not process it. A failed signature is not transient, so asking for redelivery achieves nothing and a 5xx will simply produce the same request again. Log it with the event id, the timestamp difference and which secret was tried. A rise in failures is almost always one of three things: a rotation half-completed, a clock adrift, or a body being re-serialised somewhere new in the stack.
Verification does not make the consumer idempotent
A verified webhook can still arrive twice, because delivery guarantees and authenticity are unrelated properties. Senders retry when they do not receive a timely 200, and a receiver that processed the event but responded slowly will see it again. Deduplicate on the event id inside the same transaction as the effect, exactly as you would for a queue message. Signature checking and deduplication are two separate defences and neither substitutes for the other.
Why HTTPS and IP allowlists are not substitutes
HTTPS protects the channel but says nothing about who is posting: anyone who learns the URL can send a well-formed request over HTTPS. IP allowlists are weak for a different reason — provider ranges change without notice, they are shared with every other customer of that provider, and they say nothing about payload integrity. Both are reasonable as additional filters. Neither establishes origin, which is the job the signature does.
Endpoint hygiene: what the URL itself gives away
A webhook endpoint is public by necessity, so treat the URL as known. Do not encode secrets in the path or query string, since URLs end up in access logs, proxy logs, browser history and error reports. Do not vary behaviour by URL in a way that leaks information about which events exist. And return the same response shape for a signature failure regardless of cause, so the endpoint does not become an oracle for probing.
Testing the failure paths, not just the happy one
Verification code runs on every delivery but its failure branches run almost never, which makes them the branches most likely to be wrong. Test a valid signature, a tampered body, a signature valid for a different body, a timestamp outside the window, a missing header, a malformed header, and both secrets during rotation. Each of those is a distinct branch, and a suite that only covers the valid case will pass while the endpoint accepts anything.
When the sender does not sign at all
Some services still offer no signature. The available fallbacks are all weaker: a long random component in the URL treated as a bearer secret, an allowlist of source addresses, or fetching the resource back from the provider API and ignoring the payload entirely. The last of these is the only one that is actually sound, because it moves trust to a channel you authenticate yourself, and it is worth the extra request for anything that creates or charges.
Common verification failures and their cause
Common verification failures and their cause
| Symptom | Usual cause | Where to look |
| Fails for every request | Wrong secret, or signing a different string than the sender | Compare your signed string to the docs byte for byte |
| Fails intermittently, small percentage | Clock drift against a tight tolerance | Log timestamp delta on rejection |
| Fails intermittently, specific payloads | Body parsed and re-serialised before verification | Middleware order; capture raw body earlier |
| Fails only for unicode or large numbers | JSON round trip altered the bytes | Same as above; never re-encode |
| Passes but duplicates appear | Verification fine, consumer not idempotent | Deduplicate on event id |
| Passes for a very old request | No timestamp window enforced | Add tolerance check |
| Started failing after a deploy | Rotation half-completed | Accept both secrets, log which verified |
Timestamp tolerance settings
Timestamp tolerance settings
| Tolerance | Replay window | Effect on legitimate traffic |
| 30 seconds | very small | Clock drift causes intermittent rejections |
| 5 minutes | small | Standard; absorbs drift and normal retry delay |
| 15 minutes | moderate | Workable, wider surface |
| 1 hour | large | Timestamp adds little protection |
| No check | unbounded | A captured request is replayable forever |
What each control actually establishes
What each control actually establishes
| Control | Proves origin | Proves integrity | Prevents replay |
| HTTPS | no | in transit only | no |
| IP allowlist | weakly | no | no |
| Secret in the URL | weakly | no | no |
| HMAC over the body | yes | yes | no |
| HMAC over timestamp and body | yes | yes | yes, within the window |
| Fetching the resource back from the API | yes | yes | yes |
Secret rotation, in order
Secret rotation, in order
| Step | State | What breaks if skipped |
| 1. Add new secret to receiver | Both accepted | Nothing yet |
| 2. Verify against either | Both accepted | Switching the sender drops every delivery |
| 3. Switch the sender | New in use, old still accepted | A rollback drops deliveries |
| 4. Confirm traffic under the new secret | Observed | Step 5 is taken blind |
| 5. Remove the old secret | New only | A forgotten sender keeps failing silently |
Which response to return
Which response to return
| Situation | Status | Why |
| Signature valid, event accepted | 200 / 202 | Stops redelivery; process asynchronously |
| Signature invalid | 400 or 401 | Not transient; redelivery cannot help |
| Timestamp outside window | 400 | Same reasoning; log the delta |
| Valid but duplicate event id | 200 | Already handled; suppress silently |
| Valid but processing failed | 500 | Transient; redelivery is wanted here |
Verification test cases worth having
Verification test cases worth having
| Case | Expected |
| Valid signature and fresh timestamp | Accepted |
| Body altered by one byte | Rejected |
| Signature valid for a different body | Rejected |
| Timestamp older than the tolerance | Rejected |
| Timestamp in the future beyond tolerance | Rejected |
| Signature header missing entirely | Rejected, not crashed |
| Malformed header value | Rejected, not crashed |
| Old secret during rotation | Accepted |
| Same event id delivered twice | Processed once |
Key facts
- A webhook signature must be computed over the raw request body: parsing and re-serialising JSON changes the bytes and breaks verification.
- Signature comparison must be constant-time, because an early-exit comparison leaks the expected value through timing and can be reconstructed byte by byte.
- A timestamp inside the signed payload, rejected outside a short window, is what prevents a captured request from being replayed later.
- Five minutes is the standard tolerance: below one minute ordinary clock drift causes intermittent rejections, above fifteen the replay surface widens for no benefit.
- A timestamp sent in an unsigned header provides no protection at all, because an attacker rewrites it along with the rest of the request.
- HTTPS protects the channel but not the sender: anyone who learns the URL can post a well-formed request over HTTPS.
- IP allowlists are weak because provider ranges change without notice and are shared with every other customer of that provider.
- Verification must precede acknowledgement, because returning 200 tells the sender the event was accepted.
- Verification must also precede logging the payload as a legitimate event, or the log itself becomes injectable.
- Accepting two secrets during rotation is what makes rotation possible without dropping deliveries.
- A failed signature is not transient, so it should return 4xx: a 5xx asks for a redelivery that will fail identically.
- A verified webhook can still arrive twice, because authenticity and delivery guarantees are unrelated properties.
- Deduplication must key on the event id inside the same transaction as the effect, exactly as for a queue message.
- When a sender offers no signature at all, fetching the resource back from the provider API is the only sound fallback, because it moves trust to a channel you authenticate yourself.
Frequently asked questions
My verification fails for a small percentage of deliveries. Is the secret wrong?
If it were wrong, everything would fail. Intermittent failure points at either clock drift against a tight tolerance, or a body being re-serialised before verification. Log the timestamp delta on every rejection: a consistent offset is a clock, a scatter is the body.
Is checking the sender IP address enough instead?
No. Provider IP ranges change without notice, they are shared across every customer of that provider, and they say nothing about whether the payload was altered. Use them as an extra filter if you like, never as the check.
Should I verify before or after returning 200?
Before. Returning 200 tells the sender the event was accepted, and an unverified payload has not been accepted. Verify, acknowledge, then process asynchronously.
What status should a failed signature return?
400 or 401. A failed signature is not transient, so a 5xx merely asks for the same request again. Returning 200 is worse: it tells the sender an unverified payload was accepted.
Why does my signature fail only for payloads with emoji or long numbers?
Because something is decoding and re-encoding the JSON. Unicode escaping and numeric precision are not preserved through a round trip, so the bytes you sign are no longer the bytes that were sent. Capture the raw body before any parsing.
How do I rotate a webhook secret without downtime?
Accept both secrets, switch the sender, confirm traffic is arriving under the new one, then remove the old. Log which secret verified each request, or the final step is taken blind.
Should I widen the tolerance to stop timestamp rejections?
Widening hides a clock problem and lengthens the replay window at once. Measure the drift first: if it is consistent, fix the clock; if it is occasional and small, five minutes already absorbs it.
Does a valid signature mean I can process the event immediately?
It means the payload is authentic, not that it is new. Senders retry when they do not get a timely 200, so deduplicate on the event id before acting.
Can I put the secret in the webhook URL instead?
It is weaker and it leaks. URLs appear in access logs, proxy logs, browser history and error reports, and a URL secret proves nothing about whether the body was altered in transit.
What if the provider does not sign webhooks at all?
Treat the payload as a notification rather than as data: use it only as a trigger to fetch the resource from the provider API over a channel you authenticate. For anything that creates or charges, the extra request is worth it.
Do I need to verify signatures on internal webhooks too?
If the endpoint is reachable, yes. Internal is a network property, and network boundaries move — an endpoint that was internal when written is one misconfiguration away from being public.
How do I test that verification actually rejects things?
Drive it with a tampered body, a signature valid for different content, an expired timestamp, a missing header and a malformed one. A suite covering only the valid case passes while the endpoint accepts anything.
Should the response body explain why verification failed?
No. A detailed reason turns the endpoint into an oracle for probing. Return the same shape for every failure and put the detail in your own logs.
Is replay protection still needed if my consumer is idempotent?
Yes, because they defend different things. Idempotency stops a replayed event being applied twice; the timestamp window stops an old captured request being accepted at all, including ones whose ids you have long since forgotten.
Machine-readable copy of this page:
/guide/verify-webhook-signatures.md