Back to Blog
Security

Webhook Security: Idempotency and Replay Attacks

Webhooks are essential for modern applications, but they come with security challenges. Learn how to implement idempotent webhook handlers to prevent replay attacks.

MJ
Marcus Johnson
Backend Engineer
December 28, 20244 min read
webhooksidempotencystripepayments

Webhooks are the backbone of modern application integrations. Payment processors like Stripe, communication platforms like Slack, and countless other services use webhooks to notify your application of events. But with great power comes great responsibility—and significant security risks.

The Replay Attack Problem

Consider this scenario: A customer makes a $100 payment. Stripe sends a webhook to your application, and you credit their account. Simple, right?

Now imagine an attacker intercepts that webhook and replays it 1,000 times. Without proper protection, you've just credited the customer $100,000.

What is Idempotency?

An idempotent operation produces the same result regardless of how many times it's executed. For webhooks, this means processing the same event multiple times should have the same effect as processing it once.

Common Mistakes

Mistake 1: No Event ID Tracking

// Vulnerable: Processes every webhook, even duplicates
app.post('/webhooks/stripe', async (req, res) => {
  const event = req.body;

  if (event.type === 'payment_intent.succeeded') {
    await creditUserAccount(event.data.object);
  }

  res.json({ received: true });
});

Mistake 2: Race Conditions in Deduplication

// Still vulnerable: Race condition window
app.post('/webhooks/stripe', async (req, res) => {
  const event = req.body;

  // Two requests could pass this check simultaneously
  const processed = await db.processedEvents.findOne({ eventId: event.id });
  if (processed) {
    return res.json({ received: true });
  }

  await processEvent(event);
  await db.processedEvents.insert({ eventId: event.id });

  res.json({ received: true });
});

The Correct Approach

Use Atomic Operations

app.post('/webhooks/stripe', async (req, res) => {
  const event = req.body;

  // Atomic insert - fails if event already exists
  const inserted = await db.processedEvents.insertIfNotExists({
    eventId: event.id,
    processedAt: new Date(),
  });

  if (!inserted) {
    // Event already processed - return success without reprocessing
    return res.json({ received: true });
  }

  try {
    await processEvent(event);
  } catch (error) {
    // Remove the record so we can retry
    await db.processedEvents.delete({ eventId: event.id });
    throw error;
  }

  res.json({ received: true });
});

Use Database Constraints

CREATE TABLE processed_webhook_events (
  event_id VARCHAR(255) PRIMARY KEY,
  event_type VARCHAR(100) NOT NULL,
  processed_at TIMESTAMP DEFAULT NOW(),
  payload JSONB
);

The primary key constraint ensures uniqueness at the database level, preventing race conditions.

Additional Security Measures

1. Verify Webhook Signatures

import Stripe from 'stripe';

app.post('/webhooks/stripe', async (req, res) => {
  const sig = req.headers['stripe-signature'];

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Process the verified event
});

2. Implement Event Expiration

Don't process events that are too old:

const eventTimestamp = event.created;
const now = Math.floor(Date.now() / 1000);
const MAX_AGE_SECONDS = 300; // 5 minutes

if (now - eventTimestamp > MAX_AGE_SECONDS) {
  return res.status(400).json({ error: 'Event too old' });
}

3. Use Transactional Processing

Wrap your event processing in a transaction:

await db.transaction(async (tx) => {
  // Check and insert in same transaction
  const inserted = await tx.processedEvents.insertIfNotExists({
    eventId: event.id,
  });

  if (!inserted) return;

  // Process the event within the transaction
  await tx.orders.update({ stripePaymentId: event.data.object.id }, {
    status: 'paid',
  });
});

How SecurityChecks Detects These Issues

Our analyzer looks for:

  1. Missing idempotency checks - Webhook handlers that don't check for duplicate events
  2. Non-atomic deduplication - Separate check-then-insert patterns that are vulnerable to races
  3. Missing signature verification - Handlers that don't verify webhook authenticity
  4. Side effects before confirmation - Actions taken before ensuring the event is valid

Summary

Webhook security requires:

  • Idempotency keys - Track processed events by their unique ID
  • Atomic operations - Use database constraints or atomic upserts
  • Signature verification - Confirm webhooks are from the expected source
  • Timestamp validation - Reject stale events

Implementing these correctly can be the difference between a secure payment system and a costly breach.

Enjoyed this article?

Subscribe to get the latest security insights delivered to your inbox.