Webhook Security — Delivery, Verification, and Protection
A practical guide to securing webhooks: HMAC signature verification, replay attack prevention, idempotency, endpoint hardening, and secret rotation.
Overview
A webhook endpoint is an unauthenticated POST route on the public internet. Stripe, GitHub, and Slack all hit it with events that trigger real work — creating orders, granting access, deleting data. If anyone who finds the URL can POST a forged invoice.paid and your code believes it, you’ve got a privilege-escalation problem that needs no credentials at all.
This guide covers the controls that make a webhook trustworthy end to end: what an attacker can actually do, how HMAC signature verification proves authenticity, how timestamps and idempotency keys stop replay attacks, how to harden the endpoint itself, and how to handle retries, secret rotation, and (rarely) payload encryption. Examples are in Python, FastAPI, and Node.js.
When to Use This Guide
Use it if you’re building a SaaS product that sends webhooks to customer endpoints, or consuming webhooks from third parties — Stripe, GitHub, Slack, Shopify — where a forged or replayed event has consequences. It’s also the guide to reach for after an incident: a duplicate charge from a retried delivery, a forged event that triggered real work, or a leaked whsec_ secret.
It’s less useful if your “webhooks” never leave a private network with mutual authentication at the infrastructure layer — for example, event delivery inside a service mesh with mTLS between services you control. In that case the transport already provides authenticity, and you’d invest the effort in payload validation instead of signature verification. Also skip the payload-encryption section unless you handle regulated data; TLS plus signatures covers the rest.
The Threat Model
Before writing code, know what you’re defending against. An unsigned or weakly secured webhook endpoint is exposed to five attacks:
| Attack | What the attacker does | What it costs you |
|---|---|---|
| Spoofing | Forges a legitimate-looking event (payment_intent.succeeded) | You grant access, ship goods, or confirm payments that never happened |
| Replay | Captures one valid webhook and re-sends it | Duplicate charges, duplicate orders, doubled side effects |
| Tampering | Modifies the payload in transit | Wrong amounts, wrong recipients, wrong state |
| Sniffing | Reads the payload on an HTTP (non-TLS) hop | Leaked customer data, leaked secrets embedded in the payload |
| Flooding | Sends thousands of requests to the endpoint | Your workers choke on synchronous processing — a self-inflicted DDoS |
The fixes map cleanly: signatures kill spoofing and tampering, HTTPS kills sniffing, timestamps + dedup kill replay, and async processing plus rate limiting kill flooding. The rest of this guide builds each one.
Signature Verification: The Core Defense
HMAC-SHA256 is the mechanism almost every provider uses. The sender signs the raw payload bytes with a shared secret and puts the result in a header. You recompute the signature on the bytes you received and compare. If they match, the payload came from the sender and wasn’t modified — the secret never travels over the wire.
Two details make or break this. First, sign and verify the raw request body — if you parse JSON and re-serialize it, whitespace or key ordering differences produce a different byte sequence and the signature won’t match. Second, compare with hmac.compare_digest() or crypto.timingSafeEqual(), never ==: a naive comparison short-circuits on the first wrong byte, and the timing difference is measurable enough to brute-force a signature byte by byte.
Implementing Signature Verification
On the producer side, sign the exact bytes you’re about to send:
import hmac
import hashlib
import json
import time
import uuid
import requests
WEBHOOK_SECRET = "whsec_..." # loaded from a vault or env var, never committed
def deliver_webhook(customer_url: str, payload: dict):
payload_bytes = json.dumps(payload, separators=(",", ":")).encode()
signature = hmac.new(
WEBHOOK_SECRET.encode(), payload_bytes, hashlib.sha256
).hexdigest()
headers = {
"X-Webhook-Signature": f"v1={signature}",
"X-Webhook-ID": str(uuid.uuid4()), # unique per delivery attempt
"X-Webhook-Timestamp": str(int(time.time())),
}
requests.post(customer_url, data=payload_bytes, headers=headers, timeout=10)
Note that the signature covers the serialized body, the ID is generated per delivery, and the timestamp tells the receiver how old the message is. The consumer reverses the process:
import hmac
import hashlib
import time
WEBHOOK_SECRET = "whsec_..."
MAX_AGE_SECONDS = 300 # 5 minutes
def verify_webhook(body: bytes, signature_header: str,
timestamp_header: str, webhook_id: str) -> bool:
"""Return True only if the webhook is authentic, fresh, and unseen."""
if not signature_header.startswith("v1="):
return False
expected_sig = signature_header[3:]
computed = hmac.new(
WEBHOOK_SECRET.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(computed, expected_sig):
return False
if abs(time.time() - int(timestamp_header)) > MAX_AGE_SECONDS:
return False # replayed or badly delayed
return not already_processed(webhook_id)
Replay Protection and Idempotency
A timestamp check alone isn’t enough: an attacker can replay a captured webhook inside the five-minute window. An event-ID store alone isn’t enough either — if your dedup store is down or slow, duplicates slip through and stale requests get accepted. Use both.
Redis makes the dedup check a single atomic operation:
import redis
r = redis.Redis()
DEDUP_TTL_SECONDS = 24 * 3600 # keep IDs longer than the provider retries
def already_processed(event_id: str) -> bool:
"""True if this event was seen before. SET NX is atomic, so two
concurrent deliveries of the same event can't both pass."""
return r.set(f"webhook:seen:{event_id}", "1",
nx=True, ex=DEDUP_TTL_SECONDS) is None
Two properties matter here. The check is atomic (SET ... NX), so a provider retry racing a slow first delivery can’t double-process. And returning 200 for duplicates is deliberate — tell the provider “got it” so it stops retrying, while your code skips the side effects. Design the side effects themselves to be idempotent too: “mark invoice X paid” is safe to run twice; “increment paid count” is not.
Hardening the Endpoint
The handler should reject garbage before it reaches business logic. This FastAPI version layers the checks in order of cheapness:
import json
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
ALLOWED_IPS = set(os.environ.get("WEBHOOK_IP_ALLOWLIST", "").split(","))
MAX_BODY = 1_000_000 # 1 MB
@app.post("/webhooks/billing")
async def billing_webhook(request: Request):
if request.client.host not in ALLOWED_IPS: # if the provider publishes IPs
raise HTTPException(403, "Forbidden")
if request.headers.get("content-type") != "application/json":
raise HTTPException(415, "JSON only")
body = await request.body() # raw bytes, un-parsed
if len(body) > MAX_BODY:
raise HTTPException(413, "Too large")
sig = request.headers.get("X-Webhook-Signature", "")
ts = request.headers.get("X-Webhook-Timestamp", "0")
wid = request.headers.get("X-Webhook-ID", "")
if not verify_webhook(body, sig, ts, wid):
raise HTTPException(400, "Invalid signature")
event = json.loads(body) # parse only after verification
await queue.enqueue("webhook.billing", event) # hand off, don't process here
return {"status": "accepted"}
The ordering is the point: cheap rejects (IP, content-type, size) run before the crypto work, signature verification runs before JSON parsing, and the heavy lifting is handed to a queue so the endpoint answers in milliseconds.
Retries, Backoff, and Dead Letters
Delivery is at-least-once by design: providers retry until they see a 2xx, which means duplicates are normal traffic, not an edge case. Your status codes carry meaning:
- 2xx — “delivered, stop retrying.” Return it for valid webhooks even when you only queued the work, and for duplicates.
- 4xx — “permanently wrong, don’t retry.” Bad signature, malformed payload, unknown event type.
- 5xx — “try again later.” Your database was down, the queue was full. Providers honor this and back off.
That last one is easy to get wrong. If your endpoint returns 500 because a transient dependency failed, the provider retries — good. But if it returns 500 on a payload you can never process (a bug, an unknown event shape), the provider will keep hammering you. Log it, alert on it, but return a 4xx or swallow it with a 200 and send it to a dead-letter queue for inspection.
As a producer, retry with exponential backoff and jitter — 1s, 2s, 4s, 8s, 16s plus random jitter so thousands of endpoints don’t retry in lockstep — and cap the retry window at something like 24-72 hours before dead-lettering the delivery.
On the monitoring side, alert on two signals: repeated 4xx responses (someone is probing or your secret drifted) and repeated 5xx (your endpoint is failing and the provider is queuing up retries — the next burst will be bigger). A sudden spike in X-Webhook-ID values you’ve never seen, from an IP outside the provider’s ranges, is worth paging on.
Managing Secrets and Rotation
The shared secret is the whole security model, so treat it like a password: load it from a vault or environment variable, never commit it, never put it in a URL, and scope one secret per environment and per consumer.
Rotate it on a schedule (90 days is a reasonable default) and immediately after any suspected leak. The safe pattern is a dual-secret window: configure your verifier to accept signatures from both the old and new secret for a short period, switch producers to the new secret, then retire the old one. Stripe’s dashboard supports exactly this rollover; if you’re rolling your own, verify against a list of active secrets instead of a single one.
If a secret leaks, the order of operations matters: enable the new secret first, confirm new deliveries verify, then kill the old one. Rotating in the other order creates an outage where every delivery fails verification.
When TLS Isn’t Enough: Payload Encryption
Signature + HTTPS covers almost every real-world need. Payload encryption is the exception — reserved for payloads containing regulated data (health, financial records) or paths where an intermediary must not read the content. It’s symmetric encryption on top of the signature, not a replacement for it:
import json
import os
from cryptography.fernet import Fernet
# Shared out-of-band — generate once, store in the vault for both sides.
ENCRYPTION_KEY = os.environ["WEBHOOK_ENCRYPTION_KEY"].encode()
fernet = Fernet(ENCRYPTION_KEY)
# Producer: encrypt the serialized payload, then sign the ciphertext.
ciphertext = fernet.encrypt(json.dumps(payload).encode())
# Consumer: verify the signature on the ciphertext first, then decrypt.
if verify_signature(headers, ciphertext):
payload = json.loads(fernet.decrypt(ciphertext))
The ordering is deliberate: verify the signature on the ciphertext before decrypting — you never want to decrypt-and-parse attacker-controlled input before you’ve proven it came from the sender. If your provider doesn’t offer payload encryption (most don’t), this only applies to webhooks you produce yourself.
Provider Differences
The mechanics are the same everywhere; the details differ. Don’t write one generic verifier — each provider has its own header format and quirks:
| Provider | Signature header | Timestamp | Notes |
|---|---|---|---|
| Stripe | Stripe-Signature (t=…,v1=…) | Embedded in the signed payload | Sign timestamp.body; configurable tolerance; dual-secret rotation in dashboard |
| GitHub | X-Hub-Signature-256 (sha256=…) | None | Sign raw body only; use X-GitHub-Delivery GUID for replay protection |
| Slack | X-Slack-Signature (v0=…) | X-Slack-Request-Timestamp | Sign v0:timestamp:body; 5-minute tolerance |
| Shopify | X-Shopify-Hmac-Sha256 | None | Base64-encoded HMAC, not hex — a classic verification bug source |
| Custom | Whatever you design | Whatever you include | Copy Stripe’s scheme; it’s the most battle-tested |
Complete Example: Stripe Verification in Node.js
This is the full consumer flow for the most common production case — verifying a Stripe webhook with Express. The critical detail most implementations get wrong is the body parser: express.json() consumes and re-serializes the body, so you must capture the raw bytes for the route first.
const crypto = require("crypto");
const express = require("express");
const app = express();
// Capture the raw body for signature verification — before any JSON parser.
app.use("/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"];
if (!signature) return res.status(400).send("Missing signature");
// Stripe sends "t=timestamp,v1=signature"
const parts = Object.fromEntries(
signature.split(",").map(e => e.split("="))
);
const { t: timestamp, v1: sig } = parts;
if (!timestamp || !sig) return res.status(400).send("Malformed signature");
// Reject stale deliveries (replay protection)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("Timestamp out of range");
}
// Recompute over "timestamp.rawBody"
const signedPayload = `${timestamp}.${req.body.toString()}`;
const expected = crypto
.createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(sig), Buffer.from(expected)
);
if (!valid) return res.status(400).send("Invalid signature");
const event = JSON.parse(req.body.toString());
// Idempotency: the unique index makes double-processing impossible.
const processed = await db.query(
"SELECT id FROM processed_webhooks WHERE id = $1", [event.id]
);
if (processed.rows.length > 0) {
return res.status(200).send("Already processed");
}
switch (event.type) {
case "payment_intent.succeeded":
await handlePaymentSuccess(event.data.object);
break;
case "payment_intent.payment_failed":
await handlePaymentFailure(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
await db.query(
"INSERT INTO processed_webhooks (id, type, created_at) VALUES ($1, $2, NOW())",
[event.id, event.type]
);
res.status(200).send("OK");
}
);
Testing Your Webhook Security
You can’t verify security controls with the provider’s test button alone — craft the attacks yourself:
- Send a valid signature — sign a test payload with your secret; the endpoint must accept it.
- Send a bad signature — flip one hex character; the endpoint must reject it.
- Send a stale timestamp — sign correctly but with
tfrom an hour ago; must reject. - Replay the same event — send the exact same request twice; the second must be a no-op returning 200.
- Send unsigned — no signature header at all; must reject before touching the payload.
Most providers also ship a CLI (Stripe CLI’s stripe listen + stripe trigger, GitHub’s recent-deliveries replay) that lets you test the full round-trip locally before exposing an endpoint.
What Works
- Verify the raw body, always. Sign and verify bytes, not parsed objects — serialization differences break signatures.
- Return 200 fast, process async. The endpoint’s job is to authenticate, deduplicate, and enqueue — not to run your business logic inside the provider’s timeout window.
- Store event IDs longer than the provider retries. Most providers retry for 24-72 hours; a dedup TTL shorter than that is a duplicate-processing bug waiting to happen.
- One endpoint per provider. Stripe’s signature scheme ≠ GitHub’s ≠ Slack’s. Per-provider endpoints keep verification, IP allowlists, and monitoring isolated.
- Log every delivery — event ID, provider, timestamp, signature result, and outcome — but log metadata, not payloads, or your logs become a data leak.
Common Mistakes
- Comparing signatures with
==. Usehmac.compare_digest()/crypto.timingSafeEqual()— regular comparison is a timing oracle. - Parsing JSON before verifying.
JSON.parseon a forged body runs attacker-controlled input through your parser before you’ve authenticated anything. - Timestamp-only replay protection. Five minutes is plenty of time to replay a captured webhook — you need event-ID deduplication too.
- Returning 500 for unprocessable payloads. If the payload can never succeed, a 5xx triggers endless retries; dead-letter it and return 4xx or 200.
- Processing inside the request. A burst of webhooks doing synchronous work takes down your service — exactly what the provider’s retry storm then makes worse.
- Assuming the secret is safe in the repo. Once a webhook secret is committed, treat it as compromised and rotate it immediately.
See Also
- Webhook Security companion examples — runnable producer, consumer, and deduplication code.
- API Security Checklist — Authentication to Encryption — the wider API security checklist this guide plugs into.
- Web Application Security Guide — authentication, sessions, and the rest of the attack surface.
Further Reading
- Stripe — Verify webhook signatures — the reference implementation of the
t,v1scheme. - GitHub — Validating webhook deliveries — how GitHub’s
X-Hub-Signature-256works. - OWASP Cheat Sheet — Webhook Security — the community checklist for producers and consumers.
Frequently Asked Questions
What if my webhook provider doesn't sign payloads?
You've got three fallbacks, in order of preference: IP allowlisting (restrict the endpoint to the provider's published IP ranges), a shared secret in a custom header you negotiate out-of-band, and mutual TLS if the provider supports client certificates. If none are available, treat the webhook as untrusted input: validate every field and never trigger irreversible actions — payments, deletions, access grants — on unsigned events.
Is HTTPS enough, or do I need payload encryption?
HTTPS alone is enough for almost everyone. Payload encryption adds protection against compromised TLS termination (a malicious or misconfigured proxy reading the body) and is worth considering only for regulated data — HIPAA, PCI — or when the sender requires it. Signatures, not encryption, are what prove authenticity.
How do I handle retries and duplicate deliveries?
Assume every webhook will arrive at least twice. Store each event ID in a deduplication store with a TTL longer than the provider's retry window (24-72h), check it atomically before processing, and return 200 for duplicates so the provider stops retrying. Design the side effects to be idempotent too — "set state X" is safe to re-run, "increment X" is not.
What should I do if the signing secret leaks?
Rotate immediately, in this order: configure a new secret, update your verifier to accept both old and new (a dual-secret window), confirm new deliveries verify, then revoke the old secret. Most providers (Stripe included) support this rollover natively. Treat any committed-to-git secret as leaked.
One endpoint for all providers, or one per provider?
One per provider. Signature formats, IP ranges, and payload shapes all differ; a shared endpoint couples their parsers together so a bug in one exposes the others. Separate endpoints also give you per-provider monitoring and alerting. If you've got dozens of providers, route internally by path to provider-specific handlers rather than sharing one verifier.
What status code should I return for a failed delivery?
It depends on why it failed. 4xx for permanent problems (bad signature, malformed payload — don't retry, it won't fix itself). 5xx for transient ones (database down — retry, it will fix itself). 2xx for anything you've accepted, including duplicates you've deduplicated but not re-processed.
Related Resources
API Security Checklist — Authentication to Encryption
A thorough security checklist for APIs: authentication, authorization, input validation, rate limiting, encryption, logging, and deployment hardening.
GuideWeb Application Security (OWASP Top 10)
A developer-focused walkthrough of the OWASP Top 10 — injection, XSS, broken access control, SSRF and the rest — with prevention code and a threat-modeling workflow.
RecipeImplement API Rate Limiting with Redis
Protect APIs from abuse with Redis-backed token bucket and sliding window limiters, including burst handling and distributed coordination
RecipeWebSocket Authentication and Security Patterns
How to authenticate WebSocket connections, implement token validation, and handle authorization for real-time messaging in production
RecipeSchema-Based Data Validation with Zod in TypeScript
Validate and sanitize incoming data using Zod schemas with TypeScript inference, custom refinements, and error formatting for reliable API and form validation