Your webhook receiver is a public, unauthenticated endpoint that performs privileged actions based on a JSON body from the internet. Everyone knows this and most implementations still get one of five things wrong.
The endpoint has to be public, because the sender cannot hold your credentials. So every control has to live in what the request carries, and the checks are easy to get subtly wrong in ways that pass all your tests.
This post is the five, with what correct looks like. For whoever receives webhooks from a payment provider, a source host, or any vendor.
One: verifying the signature wrong, or not at all
The vendor signs the payload with a shared secret. You recompute and compare. Three ways this fails:
Not verifying at all, because it worked without it during development and the check was a to-do.
Verifying with a non-constant-time comparison. == on strings leaks timing information. Use the comparison your language provides for this:
import hmac, hashlib
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received_signature):
return 401
Signing the parsed body rather than the raw bytes. This is the subtle one. Your framework parses JSON, you re-serialise it to verify, and key order or whitespace differs from what the sender signed. The signature fails, and under deadline someone "fixes" it by loosening the check. Capture the raw body before parsing.
Two: no replay protection
A valid signed request stays valid forever. Anyone who obtains one, from a log, a proxy, a misconfigured mirror, can send it again.
Two checks, both needed:
A freshness window. Most vendors include a timestamp in the signed payload. Reject anything older than a few minutes.
An identifier you have seen before. Store the event id, reject duplicates. This also handles the vendor's own retries, which you want anyway, because at-least-once delivery is the norm and your handler will receive duplicates without any attacker involved.
if abs(now - event.timestamp) > 300: return 400 # stale
if seen.exists(event.id): return 200 # already processed
seen.put(event.id, ttl=86400)
Note the 200 on a duplicate. Returning an error to a vendor's legitimate retry makes them retry harder.
Three: trusting the payload's contents
A signature proves the message came from the vendor. It does not prove the message means what you assume.
The common error: a payment webhook says an invoice was paid, and your handler marks it paid using the amount from the payload. Better is to treat the webhook as a notification that something happened, then fetch the authoritative state from the vendor's API using your own credentials before acting.
That inversion removes a whole class of problem, because the only thing you trust from the payload is the identifier, and everything consequential comes from a call you made.
Also check the object belongs to you. A signed event about an account that is not yours should be rejected rather than processed, and in multi-tenant systems this is where cross-tenant bugs appear.
Four: doing the work synchronously
Vendors time out webhook deliveries, usually in a few seconds, and treat a timeout as failure and retry. A handler that does the work inline will, under load, time out, get retried, start the work twice, and slow down further.
Acknowledge immediately, process asynchronously:
def handler(request):
verify_signature(request) # fast
if replayed(request): return 200
queue.publish(request.raw_body) # fast
return 200 # vendor is satisfied
Then everything from the earlier sections applies to the consumer on the other side of that queue, which is now a privileged endpoint taking input from outside.
Five: the endpoint leaks
Smaller, and it comes up in every security review.
Verbose errors. A 500 with a stack trace tells an unauthenticated caller about your internals. Return a bare status.
Distinguishable responses. If an invalid signature returns 401 and an unknown event id returns 404, you have built an oracle for which identifiers exist. Return the same thing.
Logging the raw body. Webhook payloads contain customer data and sometimes tokens, and this endpoint is frequently logged verbosely during setup and never turned down.
Verify yours in five minutes
# 1. Unsigned request: must be rejected
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://api.example.com/webhooks/vendor \
-H 'Content-Type: application/json' -d '{"event":"payment.succeeded","amount":100000}'
# want 401
# 2. Replay a valid captured request twice: second must not act twice
# 3. Valid signature, tampered amount: must be rejected
# 4. Valid signature, timestamp from last week: must be rejected
# 5. Slow handler: does it return within the vendor's timeout?
Test one is the one that finds real problems. In a surprising number of systems, an unsigned POST to the webhook path does something.
The concession
Fetching authoritative state on every webhook doubles the requests and adds latency, and for high-volume, low-stakes events, such as a page-view notification, it is not worth it. Signature plus replay protection is proportionate there.
The line is consequence again. If the handler moves money, changes entitlements, provisions access or sends something to a customer, fetch the state and do not trust the payload's values. If it updates a counter, trust the payload and move on.
The implication
A webhook receiver is the one endpoint you deliberately expose to the internet with no authentication and wire directly to business logic. It deserves the scrutiny of a login endpoint, and it usually gets the scrutiny of an internal utility.
Start with the unsigned request test. It takes a minute, and the answer tells you whether anything else here matters.