How to secure webhooks
Secure webhooks by verifying an HMAC-SHA256 signature on every request with a timing-safe comparison, rejecting any request whose signed timestamp is more than 300 seconds old, serving the endpoint only over HTTPS, and keeping the signing secret in a secrets manager. Senders also need SSRF filtering on customer-supplied URLs.
When you expose a webhook endpoint, you create a public HTTP endpoint that accepts data from the internet. Anyone who discovers that URL can send requests to it, so without these controls an attacker can forge webhooks to trigger unauthorized actions, inject malicious data, or probe your system for vulnerabilities.
Webhook security checklist
- Verify the signature before parsing the payload, and reject anything that fails.
- Compute the HMAC over the raw request bytes, not re-serialized JSON.
- Compare signatures with
hmac.compare_digestorcrypto.timingSafeEqual, never==. - Sign a timestamp along with the payload and reject anything outside a 300-second window.
- Accept requests over HTTPS only.
- Store the secret in a secrets manager, and support two active secrets during rotation.
- Validate the payload schema after verification, and rate limit the endpoint.
- Log event IDs, source IPs, and verification results, with sensitive fields redacted.
- If you send webhooks, filter customer-supplied URLs for private IP ranges through a DNS-resolving proxy.
The rest of this article explains why each of those matters and how to implement it.
The core problem: anyone can POST to your endpoint
Webhook URLs are not secret. They appear in configuration panels, get logged in various systems, and can be guessed or discovered through reconnaissance. An attacker who knows your endpoint URL can craft a request that looks exactly like a legitimate webhook.
Consider a payment webhook that marks orders as paid. If an attacker can send a fake "payment successful" event, they get free products. A webhook that provisions resources could be exploited to create unauthorized accounts. Even seemingly harmless webhooks might leak information about your system's behavior.
The fundamental security requirement is authentication: proving that a webhook actually came from the claimed sender. Everything else builds on this foundation.
Verifying webhooks with HMAC signatures
The standard approach to webhook authentication is HMAC (Hash-based Message Authentication Code) signatures. The webhook provider shares a secret key with you during setup. When sending a webhook, they compute a hash of the payload using this secret and include the result in a header. You compute the same hash and verify it matches.
An attacker without the secret cannot forge a valid signature. Even knowing the algorithm and seeing many valid signatures does not help because HMAC is designed to resist these attacks.
Most providers use HMAC-SHA256. The signature typically appears in a header like X-Signature, X-Hub-Signature-256, or similar. The exact format varies: some providers send just the hex-encoded hash, others prefix it with the algorithm name, and some use Base64 encoding. The Standard Webhooks specification, which Svix co-created, exists to end that variance: compliant providers, including OpenAI and Anthropic, all send webhook-id, webhook-timestamp, and webhook-signature headers with the same signing construction, so one library verifies them all.
Here is how verification works in practice:
import hmac
import hashlib
def verify_signature(payload, signature, secret):
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
The compare_digest function is critical. A naive string comparison like == leaks timing information that can help attackers guess the signature byte by byte. Timing-safe comparison prevents this attack.
Always verify signatures before processing any webhook data. Do not parse the JSON, access fields, or take any action until verification succeeds. Treat unverified webhooks as potentially malicious.
Preventing replay attacks with timestamps
A valid signature proves the webhook was created by someone with the secret, but it does not prove when it was created. An attacker who intercepts a legitimate webhook can resend it later. If that webhook triggers a one-time action like issuing a refund, the replay causes harm even though the signature is valid.
Timestamps solve this problem. The provider includes a timestamp in the request, often in a header or as part of the signed payload. Your verification checks that the timestamp is recent, typically within five minutes of the current time.
import time
def verify_webhook(payload, signature, timestamp, secret):
# Reject if timestamp is too old or too far in the future
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300: # 5 minutes
return False
# Verify signature (timestamp should be part of signed data)
signed_payload = f"{timestamp}.{payload}"
return verify_signature(signed_payload.encode(), signature, secret)
The timestamp must be included in the signed data. Otherwise, an attacker could replace the timestamp with a recent value while keeping the original signature. Stripe's webhook format signs the timestamp together with the payload, and Standard Webhooks signs id.timestamp.payload for the same reason.
Protecting your webhook secret
Your webhook secret is the key to your security. If it leaks, attackers can forge valid signatures until you rotate to a new secret.
Store the secret in your secrets manager or environment variables, never in code or version control. Restrict access so only the systems that verify webhooks can read it. Log access attempts to detect unauthorized retrieval.
When you rotate secrets, most providers support multiple active secrets during a transition period. Configure the new secret, update your verification code to accept either secret, wait for all in-flight webhooks to drain, then remove the old secret.
Additional security measures
HTTPS is mandatory for webhook endpoints. Without it, attackers on the network path can intercept the secret from the initial configuration or modify payloads in transit.
Consider IP allowlisting as an additional layer. Many providers publish the IP ranges their webhooks originate from. Blocking requests from other IPs stops attacks before they reach your verification code. However, IP lists change, so treat this as defense in depth rather than a primary control.
Validate the payload schema after verifying the signature. Malformed data, even if legitimately signed, should not crash your handler or corrupt your database. Define expected event types and field formats, and reject anything that does not match.
Rate limiting protects against denial of service. Even with valid signatures, a flood of webhooks could overwhelm your system. Apply reasonable limits based on your expected volume.
Finally, log all webhook activity. Record timestamps, event types, source IPs, and verification results. These logs help you investigate incidents, detect attacks, and debug integration issues. Avoid logging full payloads if they contain sensitive data, or redact sensitive fields before logging.
Preventing SSRF attacks (for webhook senders)
If you are building a webhook sender, you face a different threat: Server-Side Request Forgery (SSRF). When customers configure webhook URLs, they might supply addresses pointing to your internal network. Your webhook system would then make requests to internal services, potentially exposing sensitive data or triggering unintended actions.
This attack is particularly dangerous because webhook systems are designed to call arbitrary URLs. An attacker could register a webhook pointing to http://169.254.169.254/ (the cloud metadata endpoint) or http://internal-admin.local/ and receive responses meant only for internal systems.
Preventing SSRF requires blocking requests to internal networks before they happen. Use a proxy like Smokescreen that resolves DNS and filters out private IP ranges before allowing connections. Alternatively, place your webhook workers in an isolated network segment with no route to internal services.
At minimum, block requests to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), localhost (127.0.0.0/8), and link-local addresses (169.254.0.0/16). Be aware that attackers can use DNS rebinding to bypass naive checks, which is why resolving DNS through a filtering proxy is the more reliable solution.
Ready to send webhooks?
Svix handles signing, retries, rate limiting, and delivery observability for the webhooks you send to your users, so your team can stay focused on your product.
Start sending webhooks with Svix or read the build vs. buy analysis