Topic: webhook replay security
MCP server webhook replay security
Webhooks arrive from the public internet. Without HMAC signature verification, any actor who knows your endpoint URL can forge events. Without a timestamp replay window, verified webhooks can be replayed hours later to trigger duplicate state mutations. Without idempotency keys, legitimate redeliveries from the provider cause the same issues. All three defences are required — and they interact in ways that matter.
HMAC-SHA256 signature verification with timingSafeEqual
Webhook providers (Stripe, GitHub, Svix, etc.) sign each delivery by computing HMAC-SHA256(secret, raw_body) and including the result in a request header. The receiver recomputes the HMAC over the raw body buffer and compares. Two implementation errors are extremely common:
Error 1: comparing with === instead of timingSafeEqual. String comparison short-circuits on the first differing byte. An attacker who can observe response latency can iteratively discover the correct HMAC one byte at a time (a timing oracle). crypto.timingSafeEqual always compares all bytes in constant time.
Error 2: re-serializing the body before hashing. JSON.parse followed by JSON.stringify can reorder keys, strip whitespace, or alter Unicode escapes — changing the byte sequence. The HMAC must be computed over the raw body bytes exactly as received.
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
// Use express.raw() — NOT express.json() — for webhook routes
// app.use('/webhook', express.raw({ type: 'application/json' }), webhookMiddleware);
export function verifyWebhookSignature(secret: string) {
return (req: Request, res: Response, next: NextFunction) => {
const signature = req.headers['x-webhook-signature'] as string;
if (!signature) return res.status(401).json({ error: 'missing signature' });
// req.body is a Buffer when using express.raw()
const rawBody: Buffer = req.body;
const expected = createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const sigBuf = Buffer.from(signature, 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
// timingSafeEqual requires equal-length buffers
if (sigBuf.length !== expectedBuf.length) {
return res.status(401).json({ error: 'invalid signature' });
}
if (!timingSafeEqual(sigBuf, expectedBuf)) {
return res.status(401).json({ error: 'invalid signature' });
}
// Safe to parse the body now — signature is valid
req.body = JSON.parse(rawBody.toString('utf-8'));
next();
};
}
Note that the same error message is returned for both the length mismatch and the value mismatch — leaking which branch triggered would help an attacker craft a HMAC of the correct length.
Timestamp replay window to reject delayed replays
HMAC verification proves the webhook was sent by the provider at some point. It does not prove it was sent recently. An attacker who intercepts or is forwarded a valid webhook (e.g., from a developer's ngrok tunnel) can replay it days later. The same event — a payment, a user signup, a tool invocation — would be processed again as if it were new.
The fix: include a timestamp in the signed payload and reject requests where the timestamp is outside a ±5-minute window of the server's current time. The timestamp must be part of the signed data — otherwise an attacker can update it to a fresh value while keeping the original body.
// Webhook body: { "event_id": "evt_123", "timestamp": 1749990000, "data": {...} }
// Signature: HMAC-SHA256(secret, JSON.stringify({ event_id, timestamp, data }))
// — or the provider may concatenate: HMAC-SHA256(secret, `${timestamp}.${rawBody}`)
const REPLAY_WINDOW_SECONDS = 300; // ±5 minutes
function checkTimestamp(webhookBody: { timestamp: number }) {
const nowSeconds = Math.floor(Date.now() / 1000);
const diff = Math.abs(nowSeconds - webhookBody.timestamp);
if (diff > REPLAY_WINDOW_SECONDS) {
throw new Error(`Webhook timestamp out of window: ${diff}s ago`);
}
}
// Call after signature is verified, before processing
checkTimestamp(req.body);
NTP synchronization on the server is a prerequisite. Clock drift exceeding the replay window causes false positives. Most cloud instances synchronize automatically; verify with timedatectl status and ensure the NTP service is active. The provider's documentation specifies how the timestamp is included in the signed payload — read it carefully; some providers use a separate t=<timestamp> field concatenated with the body.
Idempotency keys for safe handling of legitimate redeliveries
Webhook providers guarantee at-least-once delivery. On a timeout (your server takes >30s) or a non-2xx response (a transient 500), the provider will redeliver the same event — with the same event_id — two or three more times over the next few hours. Without idempotency, each delivery triggers the full handler: charges processed twice, emails sent twice, state mutated twice.
const redis = new Redis(process.env.REDIS_URL);
// Idempotency window: replay_window + max_delivery_retry_window
// 5 min (replay) + 24 hours (provider retry) = 25 hours, round to 90000s
const IDEMPOTENCY_TTL_SECONDS = 90_000;
async function processWebhookIdempotent(eventId: string, handler: () => Promise) {
const key = `webhook:processed:${eventId}`;
// SET NX (only set if not exists) — atomic check-and-set
const acquired = await redis.set(key, 'processing', 'EX', IDEMPOTENCY_TTL_SECONDS, 'NX');
if (!acquired) {
// Already processing or already processed — return 200 to prevent re-delivery
// Do NOT return 409 here — a 4xx causes the provider to stop retrying entirely
console.info(`Duplicate webhook delivery for ${eventId} — skipping`);
return; // respond 200 to the caller
}
try {
await handler();
// Mark as successfully processed (overwrite 'processing' with 'done')
await redis.set(key, 'done', 'EX', IDEMPOTENCY_TTL_SECONDS);
} catch (err) {
// Delete the key on failure so the provider's retry can try again
await redis.del(key);
throw err; // re-throw → middleware returns 500 → provider retries
}
}
// Usage in the route handler
app.post('/webhook', verifyWebhookSignature(SECRET), async (req, res) => {
const { event_id } = req.body;
await processWebhookIdempotent(event_id, async () => {
await handleEvent(req.body);
});
res.status(200).json({ received: true });
});
The TTL on the Redis key must cover both the replay window (5 minutes) and the provider's maximum retry window (typically 24–72 hours for Stripe, GitHub, etc.). Too short a TTL means a legitimate late retry from the provider is processed again; too long wastes Redis memory. 25 hours (90,000 seconds) covers most providers.
Why both signature verification and idempotency are required together
Skipping signature verification and relying only on idempotency is catastrophically insecure: an attacker who learns any valid event_id (e.g., from an error log, a debug endpoint, or a previous webhook they received on their own account) can forge a webhook with that ID. The idempotency check finds the key in Redis and returns 200 without processing — the attacker has suppressed the legitimate event. The combination creates a denial-of-service on specific events: pre-register a known event_id with a forged payload, and the real event is silently dropped when it arrives.
Conversely, skipping idempotency and relying only on signature verification means every legitimate redelivery is processed again. Both checks are mandatory. The correct order is: (1) verify signature, (2) check timestamp, (3) check idempotency key, (4) process event, (5) record idempotency key.
Webhook endpoint discovery and enumeration defence
Webhook URLs are often guessable or discoverable. If your MCP server registers a webhook at /webhooks/stripe or /webhooks/github, an attacker who knows your domain can probe those paths and start sending forged events immediately — before even attempting to bypass signature verification. Adding a secret path component makes the URL itself a partial secret, reducing the attack surface for attackers who are scanning for webhook endpoints.
// Generate a secret path component at registration time
import { randomBytes } from 'node:crypto';
async function registerWebhookEndpoint(service) {
const secret = randomBytes(24).toString('base64url'); // 32-char URL-safe string
const path = `/webhooks/${service}/${secret}`;
// Register this path with the provider (e.g., Stripe dashboard, GitHub App settings)
await db.webhookEndpoints.create({
service,
path,
secret_path_component: secret,
hmac_secret: await generateHMACSecret(), // store separately
});
return path;
}
// URL looks like: /webhooks/stripe/V3kMp7qX2n... (not guessable)
This does not replace signature verification — a secret URL is security-through-obscurity and can be leaked via referrer headers, log files, or error messages. But it significantly raises the bar for attackers who are scanning for webhook endpoints. Combine it with signature verification (primary control), timestamp check (secondary), and idempotency (tertiary) for defence in depth.
Also register your webhook URLs with your monitoring system: alert if any request arrives at a webhook path that is not in the registered set. This detects probing — an attacker testing whether /webhooks/stripe exists before they have a valid payload to send. Return 404 (not 401 or 403) for unknown webhook paths to avoid confirming that webhook endpoints exist at your domain at all.
Webhook fanout and ordering guarantees
MCP servers that process webhooks and then trigger further tool calls face a webhook fanout problem: a single incoming event triggers multiple downstream operations, and if any one fails, the partial state must be reconciled. Idempotency at the webhook layer (section 3) handles duplicate deliveries, but it does not handle partial fanout.
// Webhook fanout with at-least-once delivery and partial retry
async function handlePaymentWebhook(event) {
const { event_id, data } = event;
// All operations in a single database transaction with their completion flags
await db.transaction(async (tx) => {
const existing = await tx.webhookProcessing.find(event_id);
if (existing?.completed) return; // idempotent — already fully processed
// Record the event first (idempotency anchor)
const processing = existing ?? await tx.webhookProcessing.create({
event_id,
steps_completed: [],
});
// Step 1: update order status
if (!processing.steps_completed.includes('order_update')) {
await tx.orders.updateStatus(data.order_id, 'paid');
await tx.webhookProcessing.addStep(event_id, 'order_update');
}
// Step 2: send receipt email (idempotent — email provider deduplicates by idempotency key)
if (!processing.steps_completed.includes('email_sent')) {
await sendReceiptEmail(data.customer_email, { idempotencyKey: event_id });
await tx.webhookProcessing.addStep(event_id, 'email_sent');
}
// Step 3: notify MCP tool completion
if (!processing.steps_completed.includes('tool_notified')) {
await notifyMCPToolCompletion(data.tool_session_id, event_id);
await tx.webhookProcessing.addStep(event_id, 'tool_notified');
}
await tx.webhookProcessing.markCompleted(event_id);
});
}
The step-completion pattern stores which fanout steps have been completed for each event_id. If the process crashes between step 1 and step 2, the next delivery (or a background retry job) can resume from step 2 without re-running step 1. This is essential for MCP servers where a webhook triggers a sequence of tool calls — tool invocations may not be idempotent themselves (a "send email" tool that does not accept an idempotency key will send duplicate emails on retry), so the webhook handler must track which tool calls have been made.
Store steps_completed in the database (not Redis) because the step list needs to survive Redis cache eviction. The database transaction guarantees that step flag writes and business logic writes are atomic — if the database crashes between the step write and the next step's start, the step flag is not written, and the step is retried on the next delivery.
SkillAudit findings for webhook replay security
Run a SkillAudit scan to detect webhook security gaps in your MCP server. See also MCP server API key management security.