How to Verify Webhook Signatures (And Why Most Developers Skip It)
Every payment processor, every Git hosting platform, every notification service sends webhooks. And most developers who receive those webhooks do the same thing: parse the JSON body, check the event t
Every payment processor, every Git hosting platform, every notification service sends webhooks. And most developers who receive those webhooks do the same thing: parse the JSON body, check the event type, process the data. What they skip is the most important step: verifying the signature.
This matters because webhook endpoints are, by definition, publicly accessible URLs that accept POST requests. Anyone who knows or guesses the URL can send fake webhook events. Without signature verification, your application cannot distinguish between a real Stripe payment notification and a crafted request from an attacker pretending to be Stripe.
How Webhook Signatures Work
The concept is simple: the webhook provider shares a secret with you during setup. When they send a webhook, they compute an HMAC (Hash-based Message Authentication Code) of the request body using that secret, and include the result in a header. When you receive the webhook, you compute the same HMAC with your copy of the secret and compare. If they match, the request is authentic.
# Python example
import hmac
import hashlib
def verify_signature(payload_body, signature_header, secret):
expected = hmac.new(
secret.encode('utf-8'),
payload_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)The hmac.compare_digest() function is critical. It performs a constant-time comparison that prevents timing attacks. Never use == to compare signatures — the time difference between a match and a near-match leaks information about the correct signature.
Stripe's Approach
Stripe includes a Stripe-Signature header with a timestamp and signature: t=1614556828,v1=abc123.... The timestamp prevents replay attacks — you should reject signatures older than a few minutes. Stripe's official libraries handle verification automatically, but here is what happens under the hood:
signed_payload = f"{timestamp}.{payload_body}"
expected_sig = hmac.new(webhook_secret, signed_payload, sha256).hexdigest()GitHub's Approach
GitHub sends an X-Hub-Signature-256 header containing sha256=abc123.... The HMAC is computed over the raw request body with your webhook secret.
Common Mistakes
Parsing the body before verifying. If your framework parses JSON and you reserialize it for verification, whitespace and key ordering changes will break the signature. Always verify against the raw request body bytes.
Using string comparison. As mentioned, == is vulnerable to timing attacks. Use constant-time comparison functions.
Not checking the timestamp. Without timestamp verification, an attacker who intercepts a webhook can replay it indefinitely. Reject webhooks older than 5 minutes.
Logging the webhook secret. Treat it like a password. If it appears in logs, rotate it immediately.
Sharing secrets across environments. Use different webhook secrets for development, staging, and production. If your development secret leaks, production remains secure.
What If You Skip It?
Without signature verification, an attacker can:
- Trigger fake "payment successful" events to get free service
- Send fake "subscription cancelled" events to disrupt your customers
- Inject malicious data through webhook payloads
- Trigger downstream actions (emails, deployments, data modifications) with forged events
These are not theoretical attacks. Webhook spoofing is documented in real-world security incidents. The fix takes 10 lines of code. There is no excuse to skip it.
If you need to debug webhook payloads during development, WebhookVault lets you capture and inspect requests without disabling security in production. Test with real payloads, ship with real verification.