Webhook Signature Verification: Stop Trusting Unsigned Payloads
An unverified webhook endpoint believes anyone who can send a POST request. Here is how HMAC signatures, timing-safe comparison, and replay protection make forged Stripe and GitHub events bounce off.
By Daniel A · Kraftwire Software
· 7 min readKey Takeaway
A webhook endpoint that does not verify signatures will believe anyone who can send an HTTP POST · and anyone can send an HTTP POST. Signature verification with HMAC proves the payload came from the provider and was not altered in transit, and it takes about ten lines of code to do correctly. Build the habit with the HMAC generator, then make raw-body verification the first line of every webhook handler you ship.
Why an Unverified Webhook Endpoint Is an Open Door
A webhook is just a URL on your server that a provider calls when something happens · a payment succeeds, a repo receives a push, a subscription cancels. The problem is that the URL does not know who is calling. Endpoints like /api/webhooks/stripe are guessable, and they leak through logs, error trackers, and public docs anyway.
Now think about what your handler does when it trusts the payload. If it upgrades an account when it sees checkout.session.completed, an attacker can POST a forged Stripe event and get your product for free. If it triggers a deploy when it sees a GitHub push event, a forged push can make your pipeline pull and run whatever the attacker points it at. The payload is attacker-controlled input wearing a trusted provider's costume · which is exactly the class of problem covered in API security best practices.
The fix is not to hide the URL. Obscurity buys nothing durable. The fix is to make every request prove it knows a secret that only you and the provider share.
How HMAC Signatures Work
HMAC (hash-based message authentication code) is the mechanism nearly every webhook provider uses. The idea fits in three sentences:
- You and the provider share a secret string, shown once in the provider dashboard when you create the endpoint.
- For every delivery, the provider computes
HMAC-SHA256(secret, raw request body) and sends the result in a header.
- Your server computes the same HMAC over the raw bytes it received and compares. A match proves the sender knows the secret and the body was not modified.
Because the hash covers the entire body, changing even one character of the payload changes the signature completely. Because computing a valid signature requires the secret, an attacker who knows your URL still cannot forge a passing request. Store that secret like any other server credential · in a server-side environment variable, never in frontend code · the same rules as environment variables security.
The Two Schemes You Will Actually Meet
Stripe · t= and v1=
Stripe sends a Stripe-Signature header that looks like t=1719840000,v1=5257a86.... The t is a unix timestamp, and v1 is the HMAC-SHA256 of the string timestamp + "." + rawBody, keyed with your endpoint's signing secret (it starts with whsec_). Including the timestamp inside the signed payload is what makes replay protection possible · more on that below. During secret rotation Stripe can send multiple v1 entries, and your code should accept the request if any of them matches.
GitHub · sha256=
GitHub sends X-Hub-Signature-256: sha256=<hex digest>, where the digest is a plain HMAC-SHA256 over the raw body using the webhook secret you set on the repo. There is also a legacy X-Hub-Signature header using SHA-1 · ignore it and verify the SHA-256 one. Most other providers (Shopify, Slack, Twilio) follow one of these two shapes with cosmetic differences, so once you can verify Stripe and GitHub you can verify almost anything.
One more shape exists in the wild: a few providers sign webhooks as JWTs instead of bare HMAC digests. If the header value starts with eyJ, paste it into the JWT debugger to see the claims and the signing algorithm before you write a verifier.
Verifying in Node · the Right Way
Here is a GitHub verifier in Express. The two load-bearing details are the raw body and the timing-safe comparison:
Note that express.raw() is applied to this route only, so req.body arrives as the untouched Buffer the provider actually signed. For Stripe, prefer the official library's stripe.webhooks.constructEvent(rawBody, signatureHeader, secret) · it implements the t=/v1= parsing, the tolerance window, and the comparison for you.
Timing-Safe Comparison
Comparing signatures with === returns as soon as the first byte differs, which means response time leaks how much of a guess was correct. Exploiting that over a noisy network is hard, but hard is not impossible, and the safe version is free: crypto.timingSafeEqual compares every byte regardless of where the mismatch is. It throws if the buffers differ in length, so check lengths first as the example does. There is no scenario where the fast-fail comparison is worth keeping.
Replay Protection
A valid signature proves origin and integrity · it does not prove freshness. An attacker who captures one legitimate signed request can resend it unchanged tomorrow, and the signature will still verify. Two layers close this:
- Check the timestamp. Stripe signs it into the payload precisely so you can reject events older than a tolerance window · five minutes is the common default. Compare against your server clock and refuse anything stale.
- Track event IDs. Providers send a unique ID per event (
evt_... for Stripe, the delivery GUID for GitHub). Store recently processed IDs and skip duplicates. This also makes your handler idempotent, which you want anyway because providers retry deliveries on timeouts.
The Mistakes That Break Verification
- Verifying the parsed body. This is the classic.
JSON.parse followed by JSON.stringify can reorder keys, change whitespace, and re-encode unicode · the bytes no longer match what was signed, verification fails on perfectly legitimate events, and a frustrated developer "fixes" it by deleting the check. Verify the raw bytes, always.
- Letting a global
express.json() middleware consume the body before your webhook route sees it. Mount express.raw() on the webhook path specifically.
- Using the wrong secret. Stripe issues a distinct
whsec_ per endpoint, and test mode and live mode have different ones. A signature that never verifies usually means the wrong secret, not an attack.
- Comparing with
=== instead of a constant-time function.
- Returning 200 before verifying, or leaking why verification failed in the error body. Respond
401 with nothing useful in it.
Prove It Works Before You Ship
Do not wait for a real provider event to find out your verifier is wrong. Take a sample payload, compute its HMAC-SHA256 with the HMAC generator using your test secret, and send it to your endpoint with curl · then flip one character in the body and confirm you get a 401. Two minutes of that beats a week of silently accepted forgeries. And once your webhooks are locked down, check the rest of the surface · run a free security scan to see what else your app exposes.