Most webhook security bugs are not exotic. They are the same handful of mistakes, shipped by teams who verified a signature once, saw it pass, and moved on. This is the checklist to run before a webhook endpoint goes to production. Each item is something an attacker or a bad network day will find if you skip it, and every one of them has burned a real integration.
1. Verify the signature against the raw body
This is the single most common webhook bug across every provider. The signature is an HMAC computed over the exact bytes the provider sent. If your framework parses the JSON and you rebuild a string from the parsed object to check against, the bytes are no longer identical. Key order shifts, whitespace collapses, numbers get reformatted, and a perfectly correct HMAC never matches.
Capture the raw, unparsed body, verify against it, and parse only after the check passes. The mechanics differ per framework: Express needs express.raw(), Next.js route handlers use await req.text(), Cloudflare Workers use await request.text(). The rule holds for Stripe, Paddle, HubSpot, and Slack alike. If you want the per-provider details side by side, see the webhook signature verification comparison.
2. Compare signatures in constant time
Once you have computed the expected signature, do not compare it with === or ==. A normal string comparison returns as soon as it hits the first differing character, and the tiny timing difference leaks how many leading bytes were correct. Given enough attempts, that is enough to forge a valid signature one byte at a time.
Use a constant-time comparison: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP. They take the same amount of time whether the first byte differs or the last one does. Make sure both buffers are the same length first, because timingSafeEqual throws on a length mismatch.
3. Enforce a timestamp and a replay window
A valid signature proves the request came from the provider. It does not prove the request is fresh. If an attacker captures one signed request, nothing stops them from replaying it a thousand times unless you check the timestamp.
Most providers fold a timestamp into the signed string exactly so you can reject stale requests. Stripe, HubSpot, and Slack all sign a timestamp and recommend rejecting anything older than five minutes. Read the timestamp, confirm it is within your tolerance of now, and reject it if it is not. This is also why you sign the timestamp plus the body rather than the body alone. Twilio is the exception here: it signs the request URL rather than a timestamp, so its replay story is different and worth understanding on its own in the Twilio validation guide.
4. Fail closed, and log what failed
When verification fails, return a 401 or 403 and drop the request. Do not process it "just in case." And do not fall back to processing unsigned requests because signature checking was "acting up" in staging. A webhook endpoint that accepts unsigned requests is an open door with a lock bolted to the wall next to it.
Log enough to debug a real failure without leaking secrets: the header that arrived, the timestamp, and whether the failure was a bad signature versus a stale timestamp versus a missing header. Never log the signing secret or the computed HMAC. When a provider rotates a secret or a proxy mangles a header, these logs are the difference between a five-minute fix and an afternoon.
5. Handle signing secrets like the credentials they are
-
One secret per endpoint. Stripe issues a distinct
whsec_per endpoint. Using the wrong one is a silent, total verification failure. Map each endpoint to its own secret. - Out of source, in the environment. Signing secrets belong in your secret manager or environment, not committed to the repo. A leaked signing secret lets anyone forge requests you will happily trust.
- Rotatable. Build for rotation before you need it. Support two valid secrets at once for a short window so you can roll a secret without dropping live traffic, then retire the old one.
6. Make your handler idempotent
Providers retry. A delivery that times out, or one where your app returns a 500, will be sent again, and sometimes a provider sends the same event twice even on success. If your handler charges a card, sends an email, or increments a counter without checking whether it already processed that event, retries turn into duplicate side effects.
Every provider gives you a stable event ID. Record the IDs you have processed and make the second delivery of the same ID a no-op. This is the difference between a retry being a safety net and a retry being a second charge. The exactly-once processing pattern covers how to do this without a race.
7. HTTPS only, and validate the payload shape
Serve the webhook endpoint over HTTPS so the signed request is not readable or modifiable in transit. Then treat the verified payload as input you still have to validate. A signature proves the provider sent it. It does not prove the event type is one you handle, that the fields you need are present, or that an amount is within a sane range. Check the event type against an allowlist and validate the fields you read before acting on them.
8. The item a security review usually forgets: the events you never receive
Every item above hardens the requests that arrive. None of them help with the request that never gets to you. The webhook that fired while your server was mid-deploy, the burst that timed out during a traffic spike, the event dropped because your handler threw before it acknowledged. Provider retry behavior is uneven: Stripe and Shopify retry for days, Slack retries a few times and can then disable your endpoint, Twilio barely retries at all. The events tied to money and to state are exactly the ones you can least afford to silently lose, and a signature check does nothing to catch a delivery that never happened.
That is the layer EventDock adds under the same checklist. You point any provider at EventDock instead of your app. EventDock verifies the signature against the raw body, stores the event, and acknowledges the provider immediately so no timeout window matters, then delivers to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait and arrive when it recovers instead of vanishing. Signature verification and delivery reliability are two halves of the same problem, and a webhook is only as secure as it is durable.
You can wire up any provider on the free tier and watch every event get verified, stored, and delivered. Start with EventDock free.
Top comments (0)