A webhook endpoint is just a public URL that runs code when it receives a POST — which means anyone who finds it can send you fake events unless you verify the signature correctly, including the two checks most implementations skip.
Most webhook integrations get built the same way: stand up an endpoint, point the provider's dashboard at it, parse the JSON body, and act on it. It works in testing. It's also trivially forgeable, because a webhook receiver is nothing more than a public URL that runs privileged code whenever it gets a POST request. If you don't verify who actually sent that POST, anyone who finds the URL — scraped from a leaked config, guessed from a predictable path, or just brute-forced — can send you a fake payment.succeeded event and watch your system act on it.
The fix is signature verification, and the mechanism is the same across every major provider: HMAC. But "verify the signature" hides two details that most homegrown implementations get wrong even when they remember to check it at all — comparing signatures with a regular string comparison instead of a constant-time one, and not checking when the signature was generated. Both gaps are exploitable, and neither shows up in a happy-path test.
A webhook provider and your endpoint share a secret, generated once when you register the endpoint. When an event fires, the provider computes an HMAC-SHA256 digest of the outgoing payload using that secret, and sends the digest alongside the payload in a header. Your endpoint recomputes the same digest independently, using the same secret and the same bytes, and compares the two. If they match, the request could only have been produced by someone who knows the secret — which should be just you and the provider.
Here's a real, computed example of what that looks like. Take this timestamp and payload:
timestamp: 1730000000
payload: {"type":"payment.succeeded","id":"evt_1a2b3c"}
Stripe-style signing concatenates them as timestamp.payload and runs HMAC-SHA256 with the endpoint's webhook secret:
signed_payload = "1730000000.{\"type\":\"payment.succeeded\",\"id\":\"evt_1a2b3c\"}"
signature = HMAC-SHA256(secret, signed_payload)
= ae9a42b52e1eba194611c05ca039a91043f7a6e3093b079b1e6c4068989a6e67
That 64-character hex string is what arrives in the Stripe-Signature header (as the v1= component). Change a single byte of the payload — reorder a JSON key, add whitespace, alter one digit of the amount — and the digest comes out completely different. That's the property you're actually relying on: not secrecy of the payload, but tamper-evidence of it.
The detail that trips people up: you must verify against the raw request body bytes, not the object your framework parsed and re-serialized. If your web framework parses JSON before your verification code runs, and you re-stringify it to check the signature, key ordering or number formatting differences between the original bytes and your re-serialized version will produce a different digest — and a correct, legitimate webhook will fail verification. Every provider's docs say this explicitly; it's still the most common integration bug reported against webhook SDKs.
Constant-time comparison. Comparing two hex strings with === or signature == expected looks correct and passes every test, but it leaks timing information: a naive string comparison returns as soon as it finds the first mismatched character, so a wrong guess that happens to match more leading characters takes measurably longer to reject. Given enough network requests, an attacker can use that timing difference to reconstruct the correct signature byte by byte. The fix is a constant-time comparison function — crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP — that always takes the same amount of time regardless of where the mismatch occurs.
Timestamp tolerance. A valid signature that leaks — logged somewhere, cached by a proxy, captured on a compromised network — stays valid forever unless you bind it to a time window. That's why Stripe includes the timestamp inside the signed content itself (the t= prefix in Stripe-Signature) and its libraries reject any signature older than five minutes by default. Without that check, a captured signature-and-payload pair is a replayable credential with no expiry.
The HMAC core is identical everywhere; the header format, canonicalization, and retry semantics are not.
| Stripe | GitHub | Svix / Standard Webhooks | |
|---|---|---|---|
| Signature header | Stripe-Signature | X-Hub-Signature-256 | svix-signature |
| Signed content | {timestamp}.{raw body} | raw body only | {id}.{timestamp}.{raw body} |
| Encoding | hex, prefixed v1= | hex, prefixed sha256= | base64, prefixed v1, |
| Replay protection | timestamp embedded in signed content | none in the signature itself | timestamp embedded in signed content |
| Dedupe key | id field on the Event object | X-GitHub-Delivery header | svix-id header |
| Retry policy | exponential backoff, retried for several days | automatic retry shortly after failure, plus manual redelivery from the UI/API | exponential backoff with configurable retry schedule |
The GitHub row is the interesting one: its signature scheme has no built-in timestamp, so replay protection is entirely your responsibility, done by tracking X-GitHub-Delivery IDs you've already processed rather than by rejecting old signatures. Svix (and the open Standard Webhooks spec it co-authored, now adopted by a growing list of providers) folds the delivery ID into the signed content itself, so the ID both dedupes and strengthens the signature — a forged event can't just reuse someone else's valid ID.
Every provider above retries failed deliveries, and "failed" includes cases where your endpoint actually succeeded but the acknowledgment never arrived — a load balancer timeout, a network blip on the response, a deploy that restarted your server between processing and responding. That means your endpoint will receive the same event more than once in normal operation, not just in edge cases. Verifying the signature tells you an event is authentic; it says nothing about whether you've already processed it.
The fix is the same idea covered in our piece on idempotency keys: store the provider's event ID (or the svix-id / X-GitHub-Delivery header) in a table with a unique constraint, and check it before doing any side-effecting work. A minimal verification-plus-dedupe handler looks like this:
const crypto = require('crypto');
function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const [tPart, sigPart] = header.split(',');
const timestamp = tPart.split('=')[1];
const signature = sigPart.split('=')[1];
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > toleranceSeconds) throw new Error('signature too old, possible replay');
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const valid = crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
if (!valid) throw new Error('invalid signature');
}
Note that timingSafeEqual throws if the two buffers are different lengths, so a length check (or a try/catch around the comparison) is required in production code — an attacker sending a short garbage signature shouldn't crash your handler with an unhandled exception.
Signature verification, constant-time comparison, timestamp tolerance, and event-ID deduplication are four separate checks, and a webhook handler that's missing any one of them is exploitable in a specific, demonstrable way: skip the signature and anyone can forge events; skip constant-time comparison and the secret is extractable via timing; skip the timestamp check and a leaked signature never expires; skip deduplication and a single event double-charges a customer or double-sends an email on every retry. None of these are exotic — they're exactly what Stripe's, GitHub's, and Svix's own SDKs implement for you, which is the strongest argument for using the official library instead of hand-rolling verification against the raw docs.