Webhooks Security: Signature Verification, Replay Protection, and Best Practices
A provider-agnostic guide to securing webhook endpoints with HMAC-SHA256 signature verification, timestamp-based replay attack prevention, HTTPS-only enforcement, and idempotent event processing patterns.
Webhooks are the backbone of modern event-driven integrations. GitHub sends deployment events, Twilio delivers SMS receipts, Shopify pushes order updates, PagerDuty notifies on incidents — all via HTTP POST to an endpoint you control. The security challenge is that your webhook endpoint is publicly accessible on the internet, and any HTTP client can attempt to POST to it. Without proper security controls, an attacker can forge events, replay legitimate events to trigger duplicate actions, or flood your endpoint to exhaust resources.
This guide covers the general patterns for webhook security that apply regardless of the provider, with concrete code examples.
The Threat Model
Before implementing controls, understand what you are defending against:
Forged events: An attacker crafts a fake webhook payload and POSTs it to your endpoint. Goal: trigger an action (grant access, record a payment, escalate privileges) without it actually occurring in the source system.
Replay attacks: An attacker captures a legitimate webhook request (via a man-in-the-middle or by observing network traffic) and resends it later. Goal: trigger the same action multiple times (process a payment event twice, grant access based on an old authorization).
Enumeration/probing: An attacker discovers your webhook endpoint URL and sends probe requests to understand your system's behavior — what data you accept, what errors you return, what actions different event types trigger.
Denial of service: An attacker floods your endpoint with requests, overwhelming your processing capacity and potentially affecting other parts of your application sharing the same infrastructure.
HMAC-SHA256 Signature Verification: The Pattern
The industry standard for webhook authentication is HMAC (Hash-based Message Authentication Code) using SHA-256. The provider and the recipient share a secret key. For each webhook delivery, the provider computes an HMAC over the request body (and typically a timestamp) and includes the result in a request header. The recipient recomputes the HMAC and compares it to the provided value.
The standard pattern:
- Provider signs:
HMAC-SHA256(key=SECRET, message=payload) - Provider sends:
X-Signature: sha256=<hex_digest> - Recipient receives the request
- Recipient recomputes:
expected = HMAC-SHA256(key=SECRET, message=raw_body) - Recipient compares: if
expected === received, the request is authentic
Implementation in TypeScript/Node.js:
import crypto from 'crypto';
function verifyWebhookSignature(
rawBody: string,
providedSignature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
const expected = Buffer.from(`sha256=${expectedSignature}`, 'utf8');
const received = Buffer.from(providedSignature, 'utf8');
// Use timingSafeEqual to prevent timing attacks
if (expected.length !== received.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}
Why timingSafeEqual? A naive string comparison (===) returns early as soon as it finds a mismatch. This means comparisons take slightly longer when more characters match, leaking information about the signature through timing. An attacker making thousands of requests with incrementally modified signatures can statistically infer the correct HMAC one character at a time. crypto.timingSafeEqual takes constant time regardless of where the mismatch occurs.
Timestamp-Based Replay Protection
Signature verification alone is not sufficient against replay attacks. A legitimate webhook request with a valid signature, captured and replayed hours later, would pass signature verification. The mitigation is including a timestamp in the signed payload and rejecting requests with timestamps outside an acceptable window.
This is how most webhook providers implement it. GitHub uses X-GitHub-Event + X-Hub-Signature-256. Stripe includes the timestamp in the Stripe-Signature header and in the signed payload. PagerDuty includes a webhook_id and timestamp.
For webhooks you implement yourself (as a provider or for internal services), here is the complete pattern:
Sender side:
function signWebhookPayload(payload: object, secret: string): WebhookHeaders {
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify(payload);
// Sign timestamp + body together
const signedContent = `${timestamp}.${body}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedContent, 'utf8')
.digest('hex');
return {
'Content-Type': 'application/json',
'X-Webhook-Timestamp': timestamp,
'X-Webhook-Signature': `sha256=${signature}`,
'X-Webhook-Id': crypto.randomUUID() // For idempotency
};
}
async function deliverWebhook(
url: string,
event: object,
secret: string
): Promise<void> {
const body = JSON.stringify(event);
const headers = signWebhookPayload(event, secret);
const response = await fetch(url, {
method: 'POST',
headers,
body
});
if (!response.ok) {
throw new Error(`Webhook delivery failed: ${response.status}`);
}
}
Receiver side:
const REPLAY_WINDOW_SECONDS = 300; // 5 minutes
function verifyWebhookRequest(
rawBody: string,
timestampHeader: string,
signatureHeader: string,
secret: string
): { valid: boolean; reason?: string } {
// 1. Verify timestamp is present and parseable
const timestamp = parseInt(timestampHeader, 10);
if (isNaN(timestamp)) {
return { valid: false, reason: 'Invalid timestamp' };
}
// 2. Check timestamp is within acceptable window (replay protection)
const now = Math.floor(Date.now() / 1000);
const age = Math.abs(now - timestamp);
if (age > REPLAY_WINDOW_SECONDS) {
return {
valid: false,
reason: `Timestamp too old or too far in the future: ${age}s drift`
};
}
// 3. Verify signature
const signedContent = `${timestampHeader}.${rawBody}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedContent, 'utf8')
.digest('hex');
const expected = Buffer.from(`sha256=${expectedSig}`, 'utf8');
const received = Buffer.from(signatureHeader, 'utf8');
if (expected.length !== received.length) {
return { valid: false, reason: 'Signature length mismatch' };
}
if (!crypto.timingSafeEqual(expected, received)) {
return { valid: false, reason: 'Signature mismatch' };
}
return { valid: true };
}
Express route handler using these utilities:
app.post('/webhooks/events',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body.toString('utf8');
const timestamp = req.headers['x-webhook-timestamp'];
const signature = req.headers['x-webhook-signature'];
const webhookId = req.headers['x-webhook-id'];
if (!timestamp || !signature || !webhookId) {
return res.status(400).json({ error: 'Missing required webhook headers' });
}
const verification = verifyWebhookRequest(
rawBody,
Array.isArray(timestamp) ? timestamp[0] : timestamp,
Array.isArray(signature) ? signature[0] : signature,
process.env.WEBHOOK_SECRET
);
if (!verification.valid) {
logger.warn('Webhook verification failed', { reason: verification.reason });
return res.status(401).json({ error: 'Webhook verification failed' });
}
// Process the event...
const event = JSON.parse(rawBody);
await processEvent(webhookId, event);
res.json({ received: true });
}
);
Provider-Specific Verification Patterns
Different providers use slightly different header names and signing schemes. Here are the verification patterns for common providers:
GitHub:
function verifyGitHubWebhook(rawBody: string, signature: string, secret: string): boolean {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
// Header: X-Hub-Signature-256
Twilio: Twilio uses a different approach — it sorts and concatenates query parameters, then signs the full URL + body:
import twilio from 'twilio';
function verifyTwilioWebhook(
authToken: string,
signature: string,
url: string,
params: Record<string, string>
): boolean {
return twilio.validateRequest(authToken, signature, url, params);
}
// Header: X-Twilio-Signature
Shopify:
function verifyShopifyWebhook(rawBody: string, hmacHeader: string, secret: string): boolean {
const calculated = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('base64'); // Shopify uses base64, not hex
return crypto.timingSafeEqual(
Buffer.from(calculated),
Buffer.from(hmacHeader)
);
}
// Header: X-Shopify-Hmac-Sha256
Idempotent Processing with Webhook IDs
Even with replay protection, legitimate systems may deliver the same event multiple times due to delivery failures and retry logic. Use the webhook's event ID or unique identifier as an idempotency key.
async function processEvent(webhookId: string, event: WebhookEvent): Promise<void> {
await connectDB();
// Attempt to insert a record with the unique webhook ID
// This will fail with a duplicate key error if already processed
try {
await WebhookLog.create({
webhookId,
eventType: event.type,
receivedAt: new Date()
});
} catch (err) {
if (isDuplicateKeyError(err)) {
logger.info('Duplicate webhook — skipping', { webhookId });
return; // Already processed
}
throw err;
}
// Process the event
switch (event.type) {
case 'user.created':
await handleUserCreated(event.data);
break;
case 'subscription.activated':
await handleSubscriptionActivated(event.data);
break;
default:
logger.info('Unhandled webhook event type', { type: event.type });
}
}
function isDuplicateKeyError(err: unknown): boolean {
return (
err !== null &&
typeof err === 'object' &&
'code' in err &&
err.code === 11000
);
}
The WebhookLog collection must have a unique index on webhookId. The first delivery inserts successfully and processing proceeds. Subsequent deliveries of the same event fail at the insert with a duplicate key error, which is caught and handled as a no-op.
HTTPS-Only and Secret Rotation
HTTPS is non-negotiable: Webhook secrets provide authentication, but if the webhook is delivered over HTTP, the payload and signature are transmitted in plaintext and can be captured by a network observer. Configure your webhook endpoints to only accept HTTPS connections. If a provider offers the option to configure the delivery URL, always use https://.
Secret rotation: Webhook secrets should be rotated periodically and whenever a compromise is suspected. Most providers support this. Build your webhook handler to support multiple active secrets during a rotation window:
const ACTIVE_SECRETS = [
process.env.WEBHOOK_SECRET_CURRENT,
process.env.WEBHOOK_SECRET_PREVIOUS // Keep old secret valid during rotation
].filter(Boolean);
function verifyWithAnySecret(
rawBody: string,
timestamp: string,
signature: string
): boolean {
return ACTIVE_SECRETS.some(secret =>
verifyWebhookRequest(rawBody, timestamp, signature, secret).valid
);
}
Once you have confirmed all in-flight deliveries use the new secret, remove the old secret from the list.
Endpoint URL confidentiality: Although security-through-obscurity is not a substitute for authentication, keep your webhook endpoint paths non-guessable. /webhooks/stripe is fine; /api/webhooks/events/12345-uuid-abc provides no additional security. The HMAC signature is your authentication control — the URL is just routing.