JustAppSec
Back to guides

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

  1. Attacker intercepts a valid webhook request (through network sniffing, compromised logs, or a man-in-the-middle position).
  2. The request has a valid signature because it was signed by the real provider.
  3. Attacker sends the same request to your endpoint again.
  4. 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:

ProviderLocation
StripeStripe-Signature header, t= parameter
GitHubNo delivery timestamp; use X-GitHub-Delivery + signature validation
SlackX-Slack-Request-Timestamp header
TwilioX-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

ApproachSpeedDurabilityCostBest For
In-memory SetFastestLost on restartFreeDev/testing only
Redis with TTLFastSurvives restartsLowMost production systems
Database tableSlowerFull durabilityMediumSystems 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.

Need help?Get in touch.