Webhook Security: Secrets, HMAC Signatures and Replay Protection
Webhook security comes down to a shared secret: the sender signs each request with an HMAC of the raw body, and your endpoint recomputes it, compares in constant time and rejects signed timestamps older than 5 minutes. Stripe, GitHub, Shopify and Slack all use HMAC-SHA256.
Webhook security rests on a webhook secret that only you and the sender know; the sender uses it to sign every request with an HMAC of the raw body, and your endpoint recomputes the HMAC, compares it in constant time and rejects anything that doesn't match.
Add a timestamp check or a delivery-ID check against replays, serve the endpoint over HTTPS only, and treat IP allowlists as an extra layer, not the main check.
How webhook signatures work
- You and the sender share a secret.
- For each request, the sender computes an HMAC, usually with SHA-256, over the exact body bytes (some add a timestamp or message ID) and sends it in a header.
- Your endpoint computes the same HMAC over the raw bytes it received.
- It compares the two in constant time and returns
401on a mismatch.
You have to run the check on the raw bytes, because JSON that was parsed and serialized again is a different string.
body='{"action":"opened"}'
sig=$(printf '%s' "$body" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" \
| sed 's/^.* //')
curl --json "$body" \
-H "X-Hub-Signature-256: sha256=$sig" \
-H "X-GitHub-Delivery: test-1" \
http://localhost:3000/webhooks
Webhook signature headers by provider
Providers agree on HMAC-SHA256 almost everywhere, but they differ in header names, hex or base64 encoding, prefixes and what gets signed, and those details cause most failed checks. Two outliers stand out: Twilio still signs with HMAC-SHA1 over the full URL plus alphabetically sorted POST parameters, and Discord uses an Ed25519 public-key signature instead of a shared secret.
| Provider | Signature header | Algorithm | Signed content | Replay protection |
|---|---|---|---|---|
| Stripe | Stripe-Signature (t=, v1=) | HMAC-SHA256, hex | timestamp.body | Signed timestamp; libraries allow 5 minutes |
| GitHub | X-Hub-Signature-256 (sha256=) | HMAC-SHA256, hex | Raw body | No timestamp; check X-GitHub-Delivery |
| Shopify | X-Shopify-Hmac-SHA256 | HMAC-SHA256, base64 | Raw body | No timestamp; check X-Shopify-Webhook-Id |
| Slack | X-Slack-Signature (v0=) | HMAC-SHA256, hex | v0:timestamp:body | Reject if more than 5 minutes off |
| Twilio | X-Twilio-Signature | HMAC-SHA1, base64 | Full URL plus sorted POST parameters | No timestamp; I-Twilio-Idempotency-Token marks retries |
| Discord | X-Signature-Ed25519 | Ed25519 public key, hex | timestamp + body | Timestamp is part of the signed message |
| Standard Webhooks (Svix) | webhook-signature (v1,) | HMAC-SHA256, base64 | id.timestamp.body | Svix libraries allow 5 minutes |
Stop replay attacks with timestamps and delivery IDs
A captured request can be sent again unchanged, and its signature will still match.
When the timestamp is signed (Stripe, Slack, Discord, Standard Webhooks), reject requests outside a short window. Stripe's libraries default to a 5-minute tolerance, Slack's example uses five minutes, and Svix libraries reject timestamps more than five minutes away in either direction. This needs an accurate server clock; Stripe signs each retry with a fresh timestamp, so retries pass.
Also store each delivery or event ID and ignore repeats. GitHub suggests X-GitHub-Delivery (a requested redelivery keeps the same ID), Shopify sends X-Shopify-Webhook-Id, and Standard Webhooks says to use webhook-id as an idempotency key. For senders without a signed timestamp, the ID check is your only replay defense.
import hashlib
import hmac
import time
def verify_slack(secret: bytes, headers, body: bytes) -> bool:
ts = headers.get("X-Slack-Request-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
base = b"v0:" + ts.encode() + b":" + body
mac = hmac.new(secret, base, hashlib.sha256).hexdigest()
given = headers.get("X-Slack-Signature", "").encode()
return hmac.compare_digest(f"v0={mac}".encode(), given)
Compare signatures in constant time
An ordinary == check stops at the first byte that differs, so response timing can leak how much of a guess was right, and GitHub says never to use a plain == operator. Use crypto.timingSafeEqual in Node.js (check that the lengths match first, because it throws when lengths differ), hmac.compare_digest in Python, and hash_equals in PHP.
Most failed checks come down to one of these mismatches:
- Mixing up hex and base64 encoding (Shopify and Twilio send base64, while GitHub and Slack use hex).
- Leaving a prefix like
sha256=,v0=orv1,on one side only. - Failing to split headers with multiple signatures during rotation (Stripe sends one
v1=per active secret, and Standard Webhooks sends a space delimited list), where any one match is enough.
Rotate webhook secrets without downtime
Generate secrets from a cryptographic random source (GitHub asks for a random string with high entropy) and store them in environment variables or a secret manager, not in your code. When you rotate a secret, accept both the old and the new secret for a short overlap, then remove the old one.
Stripe lets you keep the old secret active for up to 24 hours and signs each event once per active secret, while Standard Webhooks puts space delimited signatures in webhook-signature for zero downtime rotation. Shopify warns that after rotating a client secret it can take up to an hour before deliveries use the new one, and Slack's Regenerate button for the signing secret comes with no documented overlap, so have the new value ready to deploy.
Generate a secret
openssl rand -hex 32
Accept the old and new secret during rotation
const secrets = [process.env.SECRET_NEW, process.env.SECRET_OLD]
.filter(Boolean);
const valid = secrets.some((secret) => verify(secret, req));
HTTPS, IP allowlists and other layers
HTTPS is the baseline for any webhook endpoint: Stripe requires an HTTPS URL in live mode with a valid certificate and supports only TLS v1.2 and v1.3, and Twilio won't connect to a URL with a self-signed certificate.
IP allowlists add a second layer when the sender publishes its addresses. Stripe lists its webhook IPs at https://stripe.com/files/ips/ips_webhooks.json, and GitHub publishes its addresses in the GET /meta API, which changes occasionally so your list needs updates. Twilio has no fixed IP range, making X-Twilio-Signature the only reliable check there.
Don't rely on IP checks alone, and don't put a secret in the URL as your only protection, since URLs end up in logs.
Test your signature check with a real request
Capture one real delivery before you write verification code: create a free bin at view.webhooks.page, set the ingest URL as your webhook URL, and trigger an event. The viewer shows the raw body and signature headers like Stripe-Signature and X-Hub-Signature-256, while headers whose names contain secret, token, key, authorization or cookie show as redacted.
Paste the raw body, the header and your secret into the HMAC signature verifier to see if they match, or compute the expected value with the HMAC generator. Both run in the browser.
Free bins last 48 hours and take up to 100 requests a day; claim one with a free Google sign-in before it expires to keep the URL and hold new requests for 30 days. On Solo ($12 a month), endpoints on webhooks.host can check Stripe, GitHub and Shopify signatures for you.
FAQ
What is a webhook secret?
A webhook secret is a random value shared only between you and the webhook sender. The sender signs each request with it, your endpoint recomputes the signature to verify the request, and the secret itself never travels over the network. Stripe calls it the endpoint secret (which uses a whsec_ prefix, like Standard Webhooks) and Slack calls it the signing secret.
How do I verify a webhook signature?
To verify a webhook signature, compute the HMAC with the provider's algorithm over the raw request body (plus the timestamp or message ID if the provider signs them), encode the result the same way as the header (hex or base64), and compare the two in constant time. If they don't match, reject the request with 401.
Why does my webhook signature not match?
Most mismatches happen because you hashed a parsed JSON object instead of the raw bytes, mixed up hex and base64, forgot to strip or add a prefix like sha256= or v0=, or used the wrong environment's secret or one that was recently rotated. Capture a live request and run the body, header and secret through the HMAC signature verifier in your browser to check whether they match.
How do I prevent webhook replay attacks?
Reject requests with a signed timestamp older than about 5 minutes (the window Stripe's libraries, Slack's docs and Svix's libraries use), and store each request's delivery ID so you can drop duplicates (X-GitHub-Delivery for GitHub, X-Shopify-Webhook-Id for Shopify, or webhook-id for Standard Webhooks).
Is HMAC better than a bearer token for webhooks?
Yes, because a bearer token in a header or URL only proves the caller has the token, and the token travels with every request. An HMAC webhook signature proves the request body wasn't altered in transit, and the secret never leaves either server. Serve the endpoint over HTTPS either way.
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.