Webhook Replay Attack Protection
A replay attack is when an attacker captures a legitimate webhook request and sends it again. If your server processes it twice, you may charge a customer twice, provision a resource twice, or apply a refund that was already applied. This guide covers the defenses.
How Replay Attacks Work
- Attacker intercepts a valid webhook request (through network sniffing, compromised logs, or a man-in-the-middle position).
- The request has a valid signature because it was signed by the real provider.
- Attacker sends the same request to your endpoint again.
- Your server verifies the signature (it passes — the request is legitimate) and processes it again.
The signature does not protect against replay because the signature is part of the captured request.
Defense 1: Timestamp Validation
Most providers include a timestamp in the webhook. Reject requests outside your tolerance window.
const TOLERANCE_SECONDS = 300; // 5 minutes
function isTimestampValid(timestamp: number): boolean {
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - timestamp) <= TOLERANCE_SECONDS;
}
Where the timestamp lives:
| Provider | Location |
|---|---|
| Stripe | Stripe-Signature header, t= parameter |
| GitHub | No delivery timestamp; use X-GitHub-Delivery + signature validation |
| Slack | X-Slack-Request-Timestamp header |
| Twilio | X-Twilio-Signature covers URL + sorted params |
Stripe automatically validates timestamps when you use stripe.webhooks.constructEvent() with the default tolerance of 300 seconds.
Reference: Stripe — Webhook Signatures
Limitation: timestamps protect against replays outside the window, but not within it. A request replayed 30 seconds later still passes a 5-minute window.
Defense 2: Event ID Deduplication
The strongest defense. Store every processed event ID and reject duplicates.
Database-Backed Deduplication
async function processWebhook(event: WebhookEvent): Promise<Response> {
// Check for duplicate
const existing = await db.processedWebhook.findUnique({
where: { eventId: event.id },
});
if (existing) {
// Already processed — return success (idempotent)
return new Response("OK", { status: 200 });
}
// Process the event
await handleEvent(event);
// Record as processed
await db.processedWebhook.create({
data: {
eventId: event.id,
processedAt: new Date(),
},
});
return new Response("OK", { status: 200 });
}
Redis-Backed Deduplication
For high-throughput systems, use Redis with TTL:
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
const DEDUP_TTL = 60 * 60 * 24; // 24 hours
async function isDuplicate(eventId: string): Promise<boolean> {
// SET NX returns null if key already exists
const result = await redis.set(
`webhook:${eventId}`,
"1",
"EX",
DEDUP_TTL,
"NX"
);
return result === null; // null means key existed → duplicate
}
The NX flag makes this atomic — even under concurrent requests, only one will succeed.
Choosing a TTL
The dedup store does not need to keep records forever. Set the TTL to match or exceed:
- The provider's maximum retry window (Stripe retries for up to 3 days).
- Your timestamp tolerance window (at minimum).
A safe default: 72 hours (3 days) covers most providers.
Defense 3: Nonce / Unique Delivery ID
Some providers include a unique delivery ID per attempt (not just per event). This protects against duplicate deliveries from the provider itself.
// GitHub includes X-GitHub-Delivery (unique per delivery attempt)
const deliveryId = request.headers.get("x-github-delivery");
Reference: GitHub — Webhook Headers
If available, deduplicate on the delivery ID rather than (or in addition to) the event ID.
Defense 4: Idempotent Processing
Even with all the above defenses, design your handlers to be safe if called twice:
// BAD — charges the customer again on replay
await chargeCustomer(event.data.amount);
// GOOD — uses external ID as idempotency key
await db.payment.upsert({
where: { stripePaymentIntentId: event.data.object.id },
create: {
stripePaymentIntentId: event.data.object.id,
amount: event.data.object.amount,
status: "completed",
},
update: {}, // no-op if already exists
});
Complete Implementation
Combining all defenses:
import crypto from "crypto";
const TOLERANCE = 300;
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("x-webhook-signature")!;
const timestamp = parseInt(request.headers.get("x-webhook-timestamp")!);
const eventId = request.headers.get("x-webhook-event-id")!;
// 1. Verify signature
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(`${timestamp}.${body}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return new Response("Invalid signature", { status: 400 });
}
// 2. Check timestamp
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE) {
return new Response("Request too old", { status: 400 });
}
// 3. Deduplicate
const isDup = await redis.set(
`webhook:${eventId}`,
"1",
"EX",
86400 * 3,
"NX"
);
if (isDup === null) {
return new Response("OK", { status: 200 }); // already processed
}
// 4. Process idempotently
const event = JSON.parse(body);
await processEventIdempotently(event);
return new Response("OK", { status: 200 });
}
Storage Comparison
| Approach | Speed | Durability | Cost | Best For |
|---|---|---|---|---|
| In-memory Set | Fastest | Lost on restart | Free | Dev/testing only |
| Redis with TTL | Fast | Survives restarts | Low | Most production systems |
| Database table | Slower | Full durability | Medium | Systems needing audit trail |
Related Guides
Content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly, send corrections to [email protected]. Always consult official documentation and validate key implementation decisions before making design or security choices.
