Webhook Security: How to Verify and Secure Incoming Webhooks
Webhooks are the backbone of event-driven architectures, but they are also one of the most commonly misconfigured attack surfaces in web applications. An unsecured webhook endpoint is an open door for forged events, replay attacks, and data injection. This guide covers the essential security practices that every webhook consumer must implement, with real examples from Stripe, GitHub, Twilio, and Shopify.
Why Webhook Security Is Critical
When you register a webhook, you are telling a third-party service to send HTTP POST requests to your server whenever events occur. Without verification, anyone who discovers your webhook URL can send fabricated events. The consequences depend on what your webhook handler does:
- Payment webhooks: A forged
payment_intent.succeededevent could grant access to paid features without actual payment. - User management webhooks: A forged
user.createdevent could create unauthorized accounts in your system. - Deployment webhooks: A forged push event could trigger unauthorized code deployments.
- Inventory webhooks: A forged order event could manipulate stock levels or trigger fulfillment.
Signature Verification
Signature verification is the primary defense for webhook security. The webhook sender computes a cryptographic signature over the request body using a shared secret, and includes it in a header. Your server recomputes the signature and compares.
How Stripe Does It
Stripe uses HMAC-SHA256 with a versioned signature scheme:
# Stripe signature header format:
# Stripe-Signature: t=1679510400,v1=abc123...,v0=def456...
import hmac
import hashlib
import time
def verify_stripe_signature(payload, sig_header, secret):
# Parse the signature header
elements = dict(item.split('=', 1)
for item in sig_header.split(','))
timestamp = elements['t']
expected_sig = elements['v1']
# Construct the signed payload
signed_payload = f"{timestamp}.{payload}"
# Compute the expected signature
computed_sig = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(computed_sig, expected_sig):
raise ValueError("Invalid signature")
# Check timestamp tolerance (5 minutes)
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Timestamp too old")
Key features of Stripe's approach:
- Timestamp included in signature: The timestamp is part of the signed payload, preventing replay attacks (more on this below).
- Versioned signatures (
v1): If the signing algorithm ever needs to change, Stripe can add av2without breaking existing integrations. - Per-endpoint secrets: Each webhook endpoint gets its own signing secret, limiting blast radius if one is compromised.
How GitHub Does It
GitHub uses HMAC-SHA256 with a simpler header format:
# GitHub signature header:
# X-Hub-Signature-256: sha256=abc123...
import hmac
import hashlib
def verify_github_signature(payload, sig_header, secret):
expected_sig = "sha256=" + hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_sig, sig_header):
raise ValueError("Invalid signature")
How Shopify Does It
Shopify uses HMAC-SHA256 with a Base64-encoded signature:
# Shopify signature header:
# X-Shopify-Hmac-Sha256: base64encodedhmac...
import hmac
import hashlib
import base64
def verify_shopify_signature(payload, sig_header, secret):
computed = base64.b64encode(
hmac.new(
secret.encode(),
payload,
hashlib.sha256
).digest()
).decode()
if not hmac.compare_digest(computed, sig_header):
raise ValueError("Invalid signature")
Critical Implementation Details
- Always use constant-time comparison: Use
hmac.compare_digest()in Python,crypto.timingSafeEqual()in Node.js, or equivalent. Standard string comparison (==) is vulnerable to timing attacks that can leak the expected signature byte by byte. - Verify against the raw body: Compute the HMAC against the raw request body bytes, not a parsed/re-serialized version. JSON parsing and re-serialization can change whitespace, key ordering, or unicode escaping, producing a different signature.
- Store secrets securely: The webhook signing secret must be treated like an API key. Store it in environment variables or a secrets manager, never in source code.
Replay Attack Protection
Even with valid signatures, an attacker who intercepts a legitimate webhook payload can replay it later. If your handler is not idempotent, this can cause duplicate processing (double charges, duplicate orders, etc.).
Timestamp Verification
The first defense against replay attacks is checking the event's timestamp:
const TOLERANCE_SECONDS = 300; // 5 minutes
function verifyTimestamp(webhookTimestamp) {
const now = Math.floor(Date.now() / 1000);
const diff = Math.abs(now - webhookTimestamp);
if (diff > TOLERANCE_SECONDS) {
throw new Error(`Webhook timestamp too old: ${diff}s`);
}
}
Stripe includes the timestamp in both the header and the signed payload, so it cannot be tampered with. GitHub does not include timestamps, so you must rely on other replay protection mechanisms.
Event ID Deduplication
The most robust replay protection stores processed event IDs and rejects duplicates:
// Node.js with Redis for deduplication
const Redis = require('ioredis');
const redis = new Redis();
async function processWebhook(event) {
const eventId = event.id;
const key = `webhook:processed:${eventId}`;
// Try to set the key (NX = only if not exists)
// Expire after 72 hours to prevent unbounded growth
const isNew = await redis.set(key, '1', 'NX', 'EX', 259200);
if (!isNew) {
console.log(`Duplicate webhook ${eventId}, skipping`);
return { status: 200, message: 'Already processed' };
}
// Process the event
await handleEvent(event);
return { status: 200, message: 'Processed' };
}
200 OK response. If you return an error, the sender will retry the event, creating an infinite loop of duplicate deliveries.Idempotent Handler Design
Beyond deduplication, design your webhook handlers to be idempotent from the start:
- Use
INSERT ... ON CONFLICT DO NOTHING(PostgreSQL) or equivalent upserts instead of plain inserts. - Check the current state before making changes. If a payment is already marked as paid, skip processing the
payment.succeededevent. - Use the event's own ID as the idempotency key for any downstream API calls.
IP Allowlisting
As a defense-in-depth measure, restrict your webhook endpoint to only accept requests from the sender's known IP ranges. This prevents attackers from even reaching your verification logic.
Known IP Ranges
| Provider | IP Range Source | Update Frequency |
|---|---|---|
| Stripe | stripe.com/docs/ips | Published list, infrequent changes |
| GitHub | GET https://api.github.com/meta | Dynamic, check hooks array |
| Twilio | Published list | Published list |
| Shopify | Not published | N/A (use signature verification) |
# Nginx IP allowlist example for Stripe webhooks
location /webhooks/stripe {
# Stripe webhook IPs (check docs for current list)
allow 3.18.12.63;
allow 3.130.192.0/24;
allow 13.235.14.0/24;
allow 35.154.171.0/24;
deny all;
proxy_pass http://app:3000;
}
Caveats
- IP ranges change: Providers add and remove IPs. Your allowlist must be updated when they do, or legitimate webhooks will be blocked.
- Not sufficient alone: IP allowlisting is a defense-in-depth measure, not a replacement for signature verification. IPs can be spoofed in some network configurations.
- Some providers don't publish IPs: Shopify and many smaller services do not publish their webhook source IPs, making this strategy impossible.
HTTPS and TLS
Your webhook endpoint must use HTTPS. This is not optional. Without TLS:
- The webhook payload (which may contain sensitive data) is transmitted in plaintext.
- The signing secret could potentially be extracted through man-in-the-middle interception of the initial handshake.
- Most providers (Stripe, GitHub, Shopify) will refuse to send webhooks to HTTP endpoints.
Use a valid TLS certificate from a trusted CA. Self-signed certificates will be rejected by most webhook senders.
Request Body Size Limits
Webhook endpoints should enforce request body size limits to prevent denial-of-service attacks through oversized payloads:
// Express.js - limit webhook body to 1MB
app.post('/webhooks/stripe',
express.raw({ type: 'application/json', limit: '1mb' }),
(req, res) => {
// req.body is a Buffer (raw bytes for signature verification)
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(req.body, sig, secret);
// ... handle event
}
);
Error Handling and Retry Behavior
Understand how each provider handles failed deliveries:
| Provider | Retry Strategy | Max Retries | Timeout |
|---|---|---|---|
| Stripe | Exponential backoff over 3 days | ~15 retries | 30 seconds |
| GitHub | Exponential backoff | ~25 retries | 10 seconds |
| Twilio | Fixed interval | Configurable | 15 seconds |
| Shopify | Exponential backoff over 48 hours | 19 retries | 5 seconds |
Best Practices for Responses
- Return 200 quickly: Acknowledge receipt immediately and process asynchronously. If your handler takes longer than the provider's timeout, it will retry.
- Return 200 for unhandled event types: If you receive an event type you do not handle, return
200 OK. Returning400or500will cause retries for events you will never process. - Use a queue: Push webhook payloads into a message queue (SQS, RabbitMQ, Redis) for processing. This decouples receipt from processing and prevents timeouts.
// Recommended pattern: verify, queue, acknowledge
app.post('/webhooks/stripe', async (req, res) => {
try {
// 1. Verify signature
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], secret
);
// 2. Queue for processing
await queue.send({
type: event.type,
data: event.data,
id: event.id,
received_at: Date.now()
});
// 3. Acknowledge immediately
res.status(200).json({ received: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
Security Checklist
Use this checklist for every webhook integration:
- HTTPS only — Never accept webhooks over plain HTTP.
- Verify signatures — Implement HMAC verification using the provider's documented method.
- Use constant-time comparison — Never use
==for signature comparison. - Verify against raw body — Do not parse JSON before verifying the signature.
- Check timestamps — Reject events older than 5 minutes (if the provider includes timestamps).
- Deduplicate events — Track processed event IDs and reject duplicates.
- Design idempotent handlers — Assume every event may be delivered more than once.
- Enforce body size limits — Cap request body size at 1-5MB.
- IP allowlist (if possible) — Restrict to the provider's published IP ranges.
- Return 200 quickly — Acknowledge and process asynchronously.
- Store secrets securely — Signing secrets in env vars or secrets manager, never in code.
- Rotate secrets periodically — Regenerate webhook signing secrets at least annually.
Webhook security is not glamorous, but it is foundational. A well-secured webhook handler is invisible — it just works. A poorly secured one is a ticking vulnerability that will eventually be exploited. Take the time to implement it correctly from the start.
Further Reading
- Hacking APIs (No Starch Press) — Hands-on guide to API security testing, including webhook attack vectors and HMAC bypass techniques.
- Designing APIs with Swagger and OpenAPI — Covers webhook specification and security schemas in OpenAPI 3.1.
- Building Microservices (O'Reilly) — Deep dive into event-driven architecture patterns and securing service-to-service communication.