An endpoint that accepts unsigned webhooks is a public API that creates orders and marks invoices paid. Verification is ten lines, and eight of them are about getting the raw request body.
What a signature actually proves
The provider computes an HMAC over the request body using a secret you both hold, and sends the result in a header. You recompute it and compare. If the values match, two things are true: the body was produced by someone holding the secret, and nothing changed it in transit.
Three things are not true. The payload is not confidential, so you still need TLS. The request is not necessarily recent, so you still need a timestamp check. And the event is not necessarily new, so you still need idempotency on the event id. Teams routinely ship the first check and skip the other two, which leaves a valid captured request replayable for as long as the attacker likes.
HMAC is the right primitive here and not a plain hash. sha256(secret + body) is vulnerable to length extension on Merkle-Damgård constructions, which is precisely the class of mistake HMAC's inner and outer padding was designed to remove. If you want the mechanics rather than the recipe, the HMAC generator computes the same thing your library does and shows the hex and base64 forms side by side, which turns out to be the fastest way to find out which of the two your provider actually wanted.
The raw body problem
This is the cause of the overwhelming majority of "signature mismatch" tickets, and it has nothing to do with cryptography.
The provider signed a specific byte sequence. Your framework parsed that sequence into an object before your handler ran. When you then hash JSON.stringify(req.body), you are hashing a reconstruction, and reconstructions differ in ways that are invisible on screen:
- Key order changes if anything normalised the object.
- Whitespace disappears. Stripe sends compact JSON, some providers send pretty-printed payloads with two-space indentation.
- Unicode escaping flips.
"café"and"café"are the same string and different bytes. - A trailing newline is dropped, or added.
- Numbers get reformatted.
1.0becomes1, and an amount in a big-integer id loses precision entirely.
Every one of those changes the HMAC completely. So the rule is: capture the bytes before anything parses them, verify against those bytes, and only then parse. In Express that means express.raw({ type: 'application/json' }) on the webhook route, mounted before the global JSON parser. In the Next.js App Router it means await req.text() as the first line of the route handler. In Django it is request.body, untouched by any earlier access to request.POST.
One symptom worth recognising: verification that works in local testing and fails in production usually means something in production is rewriting the body. A gateway that decompresses gzip, a WAF that normalises unicode, a proxy that re-serialises JSON. Log the received byte length next to the length the provider reports and the difference shows up immediately.
Five providers, five schemes
There is no standard, so every integration is a small reading exercise. The differences that matter are which bytes go into the HMAC, which hash, and which encoding comes out.
| Provider | Header | Signed content | Encoding |
|---|---|---|---|
| Stripe | Stripe-Signature | timestamp + "." + rawBody | hex, SHA-256 |
| GitHub | X-Hub-Signature-256 | raw body | hex with sha256= prefix |
| Shopify | X-Shopify-Hmac-SHA256 | raw body | base64, SHA-256 |
| Slack | X-Slack-Signature | "v0:" + timestamp + ":" + rawBody | hex with v0= prefix |
| Twilio | X-Twilio-Signature | full URL plus sorted POST params | base64, SHA-1 |
Three traps hide in that table. Stripe's header is a list, not a value: t=1739...,v1=5257a869..., and you have to split it and use the timestamp as part of the signed string, not just as metadata. GitHub still sends the old SHA-1 X-Hub-Signature alongside the SHA-256 one for compatibility, and code that grabs the first matching header sometimes ends up verifying the weaker of the two. Twilio does not sign the body at all in the classic form-encoded case: it signs the full request URL with the sorted parameters appended, which means a reverse proxy that rewrites the host or drops the port breaks verification while the body arrives perfectly intact.
Base64 versus hex is worth one sentence because it wastes so much time: a 64-character hex string and a 44-character base64 string can be the same 32 bytes. If your value looks right but never matches, decode both and compare the bytes, which is one paste into the base64 encoder.
There is a convergence effort. The Standard Webhooks specification, used by Svix and adopted by a growing number of APIs, defines webhook-id, webhook-timestamp and webhook-signature headers with the signed content id.timestamp.body. If you are designing outbound webhooks rather than consuming them, implementing that is more useful than inventing a sixth scheme.
Timestamps and the replay window
A signature stays valid forever, which means a captured request is a reusable one. If the payload says "subscription renewed", replaying it a hundred times can extend an account for a hundred months, depending on how naive the handler is.
The defence is a timestamp inside the signed content, which is exactly why Stripe and Slack put it there. Your check has two parts: verify the signature over timestamp + separator + body, then reject the request if the timestamp is more than a few minutes old. Five minutes is the conventional tolerance in both providers' libraries. Because the timestamp is inside the HMAC input, it cannot be edited without breaking the signature.
For providers that sign only the body, the timestamp defence is unavailable and idempotency has to do the whole job: store the event id, ignore ids you have already processed, keep the store for longer than the provider's retry schedule. Do that anyway, because at-least-once delivery means duplicates arrive without an attacker.
Compare signatures with a constant-time function, crypto.timingSafeEqual in Node or hmac.compare_digest in Python. Wrap it so that a length mismatch returns false instead of throwing, which is the usual reason a "timing-safe" comparison ends up inside a try block that swallows the failure.
Rotating a signing secret
Webhook secrets leak the same way every other secret leaks: a screenshot, a committed .env, a HAR file attached to a support ticket. Rotation should be a routine operation, not an incident procedure, and it only works if the receiver can hold two secrets at once.
The pattern: verify against the new secret, fall back to the old one on failure, log which one matched, and remove the old secret once the log stops mentioning it. Stripe builds this in by letting an endpoint carry a second signing secret with an expiry. Where the provider does not, an array of secrets in your config does the same job.
Store the secret where the rest of your secrets live, not in the repository. A webhook secret in a public commit is one of the fastest-scanned patterns on GitHub, and the usual way it lands there is a .env that .gitignore never actually ignored, because the file had already been tracked before the rule was added. Rotating the secret is the only real fix once it has been pushed; deleting the commit is not.
Debugging a mismatch
When the values disagree, work down this list in order. It is the order of how often each one is the answer.
- Are you hashing the raw bytes, or a reserialised object? Log
rawBody.lengthand compare withContent-Length. - Right secret for the right endpoint? Providers issue one per endpoint, and the test-mode and live-mode secrets differ. Stripe's are prefixed
whsec_for both, so they look identical at a glance. - Right signed content? Stripe and Slack prepend the timestamp, GitHub and Shopify do not.
- Right encoding? Hex against base64 is the classic, and comparing a string with a Buffer silently fails in Node.
- Is something between the client and your handler modifying the body? Compare a hash of what you received with what the provider logged as sent.
- Is the header being read case-correctly, and is it the header you think? GitHub's SHA-1 and SHA-256 headers differ by a suffix.
Two tools shorten this loop. Paste the payload into the webhook payload viewer to read the event structure and the headers without piping it through your own code, and compute the expected HMAC by hand in the HMAC generator with the exact string you believe is being signed. If the manual result matches the header, the bug is in your body handling; if it does not, the bug is in your signed-content assumption. That single split saves most of the afternoon.
What the endpoint should do
Verification is only the front door. The handler around it has its own set of rules, most of them learned the hard way.
- Verify before parsing, and before touching a database.
- Return quickly. Most providers time out between 5 and 30 seconds and treat a slow response as a failure, so queue the work and return 200 immediately. Stripe documents this explicitly as the reason for delivery retries.
- Return 400 on a bad signature, 200 on an event you choose to ignore. A 500 puts you in the retry schedule, which is a self-inflicted denial of service when a deploy breaks the handler for ten minutes.
- Make the handler idempotent on the event id. Retries are normal, and two identical "payment succeeded" events must not ship two orders.
- Log the event id, type and verification result. Never log the signature, the secret or the full payload, since payloads carry customer data.
- Do not trust the payload's own claim about state. For anything that moves money, treat the webhook as a notification and re-read the object from the provider's API before acting on it.
The last one is the habit that ages best. A signed webhook tells you something happened; the provider's API tells you what is true now, and those two answers diverge more often than the payload suggests.
Signature verification questions
Why is my Stripe webhook signature invalid in Express?
Because express.json() already parsed and discarded the raw body, and JSON.stringify(req.body) is not the same bytes Stripe signed. Mount the webhook route with express.raw({ type: "application/json" }) before any JSON middleware, and pass the resulting Buffer straight to constructEvent. If the route must sit behind a global parser, use the verify callback (express.json({ verify: (req, res, buf) => { req.rawBody = buf } })) and sign against req.rawBody. Key ordering, whitespace and unicode escaping all change on a reserialize, and any one of them breaks the HMAC.
How do I get the raw request body in Next.js, Fastify or Django?
In a Next.js App Router route handler, await req.text() gives you the raw string before any parsing; in the Pages Router you must first disable the parser with export const config = { api: { bodyParser: false } } and then read the stream. Fastify takes an addContentTypeParser for application/json that keeps the buffer. Django gives you request.body directly, but only if nothing has touched request.POST first. Rails exposes request.raw_post. In all of them the rule is the same: read the bytes before the framework turns them into objects.
How large should the timestamp tolerance for a webhook be?
Five minutes is the convention and it is what Stripe and Slack both use by default. Shorter than about a minute and you will reject legitimate deliveries when the provider retries or when your host clock drifts; longer than about fifteen and the replay window stops being meaningful. Whatever you pick, run NTP on the receiving host, because a clock that is quietly two hours off looks exactly like an attack in the logs.
How do I rotate a webhook signing secret without losing events?
Accept both secrets for an overlap window: verify against the new secret, and if that fails, against the old one, then remove the old one after the window. Stripe supports this directly by letting an endpoint hold a second signing secret with an expiry, and the same two-key pattern works anywhere you control the verification code. Rotate on a schedule and after every incident where the secret could have been read, including a leaked .env file or a HAR file shared in a ticket.
Do I still need HTTPS if the webhook payload is signed?
Yes. A signature gives you integrity and authenticity, not confidentiality: anyone on the path still reads the payload, which routinely contains email addresses, amounts and internal identifiers. It also does nothing about an attacker who records a valid request and replays it, which the timestamp window handles only if you implement it. Signature verification and TLS solve different problems and you want both.
Can a proxy or WAF break webhook signature verification?
It can, and it is a genuinely annoying failure because everything looks correct in your code. Anything that re-encodes the body invalidates the HMAC: a proxy that decompresses gzip, a WAF that normalises unicode or strips a trailing newline, an API gateway that re-serialises JSON, or a load balancer configured to rewrite charset. Prove it by logging the byte length and a SHA-256 of the received body and comparing it against what the provider says it sent.
What HTTP status should I return when a webhook signature does not match?
Return 400 and do not retry-loop the sender. A 400 tells the provider the request was rejected and most of them will stop retrying that delivery, which is what you want for a forged request; a 500 invites the retry schedule to hammer you for a day. Log the event id, the timestamp and the reason, but never log the signature or the secret, and keep the response body empty so a probe learns nothing about why verification failed.
Is an IP allowlist enough instead of verifying the signature?
No, and it is the substitution we see most often. Provider IP ranges change, are usually shared cloud egress addresses that any customer of the same provider can send from, and give you nothing about whether the body was modified in flight. An allowlist is a reasonable extra layer in front of verification, never a replacement for it. The signature is the only check that ties the exact bytes you received to a secret only you and the sender hold.