Webhook Security: How to Validate, Verify and Protect Endpoints
Webhooks are HTTP callbacks that push events from one service to yours. They power payment confirmations, GitHub CI triggers, Slack notifications, and countless other integrations. But an unsecured webhook endpoint is an open door for forged payloads, replay attacks, and denial of service. This guide covers everything you need to build production-safe webhook receivers.
The Threat: What Happens Without Webhook Security
Consider a payment webhook endpoint. Stripe calls your /webhooks/stripe endpoint when a charge succeeds. Your handler marks the order as paid and triggers fulfillment. Now imagine an attacker discovers your endpoint URL and sends a forged charge.succeeded event. Without signature verification, your system processes the fake payment and ships goods that were never paid for.
This is not hypothetical. Webhook fraud is a well-documented attack vector against e-commerce platforms, SaaS billing systems, and CI/CD pipelines. The attack requires only that the attacker knows your webhook URL - which is often guessable or exposed in client side code.
The threats against webhook endpoints fall into four categories:
- Forged payloads: attacker sends fake events your system processes as legitimate
- Replay attacks: attacker captures a legitimate webhook and replays it later to trigger duplicate actions
- Timing attacks: attacker uses response-time differences to infer whether a signature is partially correct
- Denial of service: attacker floods your endpoint with webhook requests, exhausting your processing capacity
Step 1: HMAC Signature Verification
The industry standard for webhook authentication is HMAC-SHA256 signature verification. The sender (Stripe, GitHub, Shopify, etc.) shares a secret key with you when you register the webhook. For each event, the sender computes an HMAC of the raw request body using that secret and sends it in a header. You compute the same HMAC and compare.
Node.js Implementation
const crypto = require('crypto');
/**
* Verify a webhook signature using HMAC-SHA256.
* @param {Buffer|string} rawBody - The raw request body (before JSON.parse)
* @param {string} signature - The signature from the webhook header
* @param {string} secret - Your shared webhook secret
* @returns {boolean}
*/
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// timingSafeEqual prevents timing attacks
// Both buffers must be the same length
const sigBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
if (sigBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(sigBuffer, expectedBuffer);
}
// Express middleware - MUST use raw body, not parsed JSON
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
const secret = process.env.STRIPE_WEBHOOK_SECRET;
// Stripe uses a more complex format: t=timestamp,v1=signature
// Use their official library for production:
// const event = stripe.webhooks.constructEvent(req.body, sig, secret);
if (!verifyWebhookSignature(req.body, extractSignature(sig), secret)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// process event...
res.sendStatus(200);
});
Critical detail: you must verify the signature against the raw request body bytes, not the parsed JSON object. JSON parsers may normalize whitespace or key order, producing a different byte sequence that will never match the sender's signature.
Python Implementation
import hmac
import hashlib
def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
"""
Verify HMAC-SHA256 webhook signature.
Uses hmac.compare_digest to prevent timing attacks.
"""
expected = hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
# Django/Flask example
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse, HttpResponseForbidden
@csrf_exempt
def stripe_webhook(request):
payload = request.body # raw bytes
sig = request.META.get('HTTP_STRIPE_SIGNATURE', '')
secret = settings.STRIPE_WEBHOOK_SECRET
if not verify_webhook_signature(payload, sig, secret):
return HttpResponseForbidden('Invalid signature')
event = json.loads(payload)
# process event...
return HttpResponse(status=200)
Step 2: Prevent Replay Attacks with Timestamp Validation
A valid signature proves the payload was signed by someone with your secret. But an attacker can capture a legitimate webhook request and replay it minutes or hours later. The signature still validates - because the payload is unchanged.
The defense is a timestamp check. Most webhook providers (Stripe, GitHub, Twilio) include the event timestamp in the signed payload or a separate header. Reject any webhook where the timestamp is older than a tolerance window (typically 5 minutes):
function verifyWithTimestamp(rawBody, signatureHeader, secret) {
// Stripe format: "t=1614556800,v1=abc123..."
const parts = signatureHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
const signature = parts.find(p => p.startsWith('v1=')).slice(3);
// 1. Check timestamp freshness (5-minute window)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old - possible replay attack');
}
// 2. Verify signature over timestamp + body
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
throw new Error('Invalid signature');
}
return JSON.parse(rawBody);
}
Step 3: Idempotency - Process Each Event Exactly Once
Webhook providers retry delivery when your endpoint returns a non-2xx response or times out. This means the same event may be delivered more than once. If your handler charges a customer or sends an email on each delivery, duplicates cause real problems.
The solution is idempotency: store the event ID after first processing and skip duplicate deliveries:
// Redis-based idempotency check
async function processWebhookIdempotent(event, redis) {
const idempotencyKey = `webhook:processed:${event.id}`;
// SET key value NX EX 86400 - set only if not exists, expire in 24h
const wasNew = await redis.set(idempotencyKey, '1', 'NX', 'EX', 86400);
if (!wasNew) {
console.log(`Duplicate webhook ${event.id} - skipping`);
return { skipped: true };
}
// Process the event exactly once
await handleEvent(event);
return { processed: true };
}
Use a TTL that exceeds the provider's retry window. Stripe retries for up to 3 days, so a 4-day TTL on the idempotency key provides full coverage.
Step 4: Respond Quickly, Process Asynchronously
Most webhook providers have a response timeout of 10–30 seconds. If your endpoint takes too long to respond, the provider marks the delivery as failed and retries. If your processing logic (database writes, third-party API calls) takes longer than the timeout, you will receive duplicates.
The solution is to acknowledge immediately and process asynchronously:
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify signature (fast - just a hash comparison)
if (!verifyWebhookSignature(req.body, req.headers['x-signature'], process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}
const event = JSON.parse(req.body);
// 2. Enqueue for async processing
await queue.add('webhook', event);
// 3. Respond immediately with 200
res.sendStatus(200);
// Processing happens in a worker - not in this request handler
});
Queue options: BullMQ (Redis-backed, Node.js), Celery (Python), AWS SQS, Google Cloud Tasks, or a simple database job queue with a worker process.
Generate and Test HMAC Signatures
Compute HMAC-SHA256 signatures for any message and key. Perfect for testing your webhook verification logic before connecting a live provider.
Open HMAC GeneratorStep 5: IP Allowlisting
Many webhook providers publish the IP ranges they send from. Adding an IP allowlist as a defense in depth measure means that even if an attacker somehow obtains your webhook secret, they cannot send webhooks from an unauthorized IP:
# Nginx: restrict webhook endpoint to Stripe's IP ranges
location /webhooks/stripe {
# Stripe IP ranges (check https://stripe.com/docs/ips for current list)
allow 3.18.12.63/32;
allow 3.130.192.231/32;
allow 13.235.14.237/32;
allow 52.15.183.38/32;
deny all;
proxy_pass http://app_server;
}
Important: IP allowlisting should be a secondary control, not the primary one. IP ranges change when providers update their infrastructure, and relying solely on IP filtering without signature verification leaves you vulnerable during transition periods.
Step 6: Always Use HTTPS
Never expose a webhook endpoint over plain HTTP. Without TLS, the signature header is transmitted in cleartext and can be intercepted by a network-positioned attacker. Once they capture the signature and timestamp from one valid request, they can replay it within the tolerance window.
In addition to using HTTPS, configure TLS correctly:
- Use TLS 1.2 minimum; prefer TLS 1.3
- Disable SSLv3, TLS 1.0, TLS 1.1
- Use strong cipher suites (ECDHE + AES GCM)
- Enable HSTS with a long max-age
Check your TLS configuration with our SSL Checker tool.
Step 7: Rate Limiting the Webhook Endpoint
An attacker can flood your webhook endpoint with requests, even if every one fails signature verification. The verification check itself has a cost, and at high volume it can exhaust CPU or connection pool resources. Add rate limiting at the nginx or application level:
# Nginx rate limiting for webhook endpoints
limit_req_zone $binary_remote_addr zone=webhook:10m rate=100r/m;
server {
location /webhooks {
limit_req zone=webhook burst=20 nodelay;
limit_req_status 429;
proxy_pass http://app_server;
}
}
100 requests per minute per IP is generous for legitimate webhook providers (which typically deliver at much lower rates) and tight enough to stop a naive flood attack.
Step 8: Logging and Alerting
Every failed signature verification should be logged with the source IP, timestamp, and event type. A sudden spike in verification failures from a single IP is a signal of an active attack. Log to a centralized system (CloudWatch, Datadog, ELK) and set alerts:
function verifyAndLog(rawBody, signature, secret, req) {
const isValid = verifyWebhookSignature(rawBody, signature, secret);
if (!isValid) {
logger.warn('Webhook signature verification failed', {
ip: req.ip,
userAgent: req.headers['user-agent'],
eventType: tryParseEventType(rawBody),
timestamp: new Date().toISOString()
});
// Increment counter metric for alerting
metrics.increment('webhook.signature.failed');
}
return isValid;
}
Provider-Specific Notes
- Stripe: use
stripe.webhooks.constructEvent()- it handles timestamp + signature verification and returns a typed event object - GitHub:
X-Hub-Signature-256header, HMAC-SHA256 of the raw body - Shopify:
X-Shopify-Hmac-Sha256, Base64-encoded HMAC-SHA256 - Twilio: custom signature algorithm using URL + sorted POST parameters
- Slack:
X-Slack-Signature, includes version prefix (v0=)
Always use the official SDK when available. SDKs handle edge cases (header format variations, encoding differences) that custom implementations often miss.
Frequently Asked Questions
Why use timingSafeEqual instead of ===?
A regular string comparison (=== or ==) short-circuits as soon as it finds the first differing character. An attacker can measure response times to determine how many leading characters of their forged signature are correct - this is a timing attack. crypto.timingSafeEqual (Node.js) and hmac.compare_digest (Python) always take the same amount of time regardless of where the strings differ, preventing this attack.
What happens if I lose my webhook secret?
Rotate it immediately in the provider's dashboard and update your application's environment variable. During the rotation window, you may miss some events if the old secret is revoked before the new one is deployed. Most providers let you have two active secrets simultaneously for a short rotation window. Treat webhook secrets with the same care as database passwords: store in a secrets manager, never in source code.
Should I verify webhooks from internal services?
Yes, even for internal webhooks between your own microservices. Mutual TLS (mTLS) or shared HMAC secrets prevent a compromised service from injecting fake events into another service's webhook pipeline. Defense in depth applies within your own infrastructure.
How do I test webhook signature verification locally?
Use the webhook provider's CLI tools (e.g., stripe listen --forward-to localhost:3000/webhooks) to forward live events to your local server. The CLI automatically signs requests with your test webhook secret. For unit tests, pre-compute the expected HMAC for a test payload and assert your verification function accepts and rejects correctly.
What HTTP status code should I return for invalid signatures?
Return 401 Unauthorized for invalid or missing signatures. Do not return 200 OK for invalid requests - this tells the provider the event was processed successfully and it will not retry. Return 400 Bad Request for malformed payloads. Return 200 OK only when the event has been accepted for processing (even if processing is async).
Security Checklist
- Verify HMAC-SHA256 signature on every incoming request
- Use
timingSafeEqual/compare_digestfor comparison - Validate timestamp freshness (reject events older than 5 minutes)
- Store and check event IDs for idempotency
- Acknowledge immediately, process asynchronously via queue
- Enforce HTTPS with TLS 1.2+
- Apply IP allowlisting as a secondary control
- Rate-limit the endpoint
- Log all verification failures with IP and event metadata
- Rotate secrets via a secrets manager; never hardcode
Use our free HMAC Generator
Generate and verify HMAC-SHA256 signatures for any message. Useful for testing webhook handler logic, debugging signature mismatches, and understanding the format your provider sends.
Use our free tool here →Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.