Webhooks are HTTP callbacks: your service calls someone else's endpoint when an event occurs. They're simple to implement on the sending side and enormously useful for integrations — but they introduce a trust problem on the receiving side. How does the receiver know the request came from your service and not from an attacker who discovered the endpoint URL? The answer is webhook signatures, and most webhook implementations get at least one detail wrong.
HMAC-SHA256 signature basics
The standard approach is HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256). The webhook sender and receiver share a secret key. The sender signs the request payload with this key and includes the signature in a header. The receiver recomputes the signature from the payload it received and compares — if they match, the payload is authentic.
import crypto from 'crypto';
// Sender: computing the signature
function signWebhookPayload(
payload: string, // raw request body, as a string
secret: string,
timestamp: number // Unix timestamp in seconds
): string {
// The signed message includes both the timestamp and the payload
// This prevents replay attacks (timestamp binds the signature to a point in time)
const signedContent = `${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', secret)
.update(signedContent, 'utf8')
.digest('hex');
return `t=${timestamp},v1=${signature}`;
}
// Example header value:
// Bastionary-Signature: t=1705305600,v1=3a7f9e2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f
// Receiver: verifying the signature
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300 // 5-minute replay window
): void {
if (!signatureHeader) {
throw new WebhookError('Missing signature header');
}
// Parse the header: t=timestamp,v1=signature
const parts = signatureHeader.split(',');
const timestampPart = parts.find(p => p.startsWith('t='));
const signaturePart = parts.find(p => p.startsWith('v1='));
if (!timestampPart || !signaturePart) {
throw new WebhookError('Malformed signature header');
}
const timestamp = parseInt(timestampPart.slice(2), 10);
const receivedSignature = signaturePart.slice(3);
// 1. Validate timestamp to prevent replay attacks
const now = Math.floor(Date.now() / 1000);
const age = Math.abs(now - timestamp);
if (age > toleranceSeconds) {
throw new WebhookError(
`Webhook timestamp too old: ${age}s (max ${toleranceSeconds}s)`
);
}
// 2. Recompute the expected signature
const signedContent = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedContent, 'utf8')
.digest('hex');
// 3. Constant-time comparison — CRITICAL
// Regular string comparison leaks timing information that can be exploited
if (!crypto.timingSafeEqual(
Buffer.from(receivedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
)) {
throw new WebhookError('Signature mismatch');
}
}
Why constant-time comparison matters
Timing attacks exploit the fact that a string comparison that exits early (when it finds the first mismatched character) takes slightly less time than one that checks the full string. By sending many requests with different signature values and measuring response times, an attacker can statistically determine the correct signature one byte at a time.
crypto.timingSafeEqual in Node.js always takes the same amount of time regardless of where the comparison fails. Python's hmac.compare_digest provides the same guarantee. Never use === or string equality for comparing cryptographic values.
// Express: capture raw body for webhook verification
import express from 'express';
const app = express();
// Use express.raw() for webhook endpoints — NOT express.json()
app.post('/webhooks/bastionary',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8'); // Buffer -> string
const signature = req.headers['bastionary-signature'] as string;
try {
verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).json({ error: (err as Error).message });
}
const event = JSON.parse(rawBody);
// Process event...
res.status(200).json({ received: true });
}
);
Idempotency: handling retries safely
Webhook delivery systems retry on failure. Your endpoint may receive the same event multiple times. Your processing logic must be idempotent — receiving the same event twice should produce the same outcome as receiving it once. The most reliable approach is to track event IDs and skip already-processed events:
async function processWebhookEvent(eventId: string, event: any, db: DB) {
// Check idempotency key
const alreadyProcessed = await db.webhookEvents.exists(eventId);
if (alreadyProcessed) {
return { status: 'already_processed' };
}
// Process the event in a transaction, recording the event ID atomically
await db.transaction(async (trx) => {
await handleEvent(event, trx);
await trx.webhookEvents.insert({
eventId,
processedAt: new Date(),
eventType: event.type,
});
});
return { status: 'processed' };
}
The unique event ID should come from the webhook payload — most webhook systems include an id field in the payload. Store processed IDs in a database table with the event ID as a primary key or unique constraint. A concurrent duplicate delivery will hit a unique constraint violation, which you catch and treat as an already-processed event.
Rotating webhook secrets
Webhook secrets should be rotatable. During rotation, support both the old and new secrets for a transition period — this prevents delivery failures while the sender is being updated:
async function verifyWithRotation(
rawBody: string,
signatureHeader: string,
endpoint: WebhookEndpoint
): Promise {
const secrets = [endpoint.currentSecret];
if (endpoint.previousSecret && endpoint.previousSecretExpiresAt > new Date()) {
secrets.push(endpoint.previousSecret);
}
for (const secret of secrets) {
try {
verifyWebhookSignature(rawBody, signatureHeader, secret);
return; // verified with at least one valid secret
} catch {
// Try next secret
}
}
throw new WebhookError('Signature verification failed with all active secrets');
}
Bastionary's webhook delivery system (SVix-style) supports dual-secret rotation natively. The transition window is configurable — typically 24-72 hours, giving integration owners time to update their verification logic without any delivery interruption.