API Security

Stripe Webhook Security: Signature Verification and Replay Attack Prevention

How to correctly implement Stripe webhook signature verification using stripe.constructEvent(), understand the Stripe-Signature header format, prevent replay attacks with timestamp validation, and implement idempotent event processing.

January 1, 20267 min readShipSafer Team

Stripe webhooks deliver real-time payment events to your application — subscription renewals, payment confirmations, disputes, refunds. If your webhook endpoint does not properly verify incoming requests, an attacker can send forged webhook events, triggering unauthorized actions like crediting accounts, bypassing payment gates, or marking orders as paid without actual payment. Stripe provides a robust signature verification mechanism, and implementing it correctly takes about twenty minutes. Implementing it incorrectly is surprisingly common.

The Stripe-Signature Header

Every webhook request from Stripe includes a Stripe-Signature header. This header contains two components:

Stripe-Signature: t=1694000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e6cbe351cc6a50cf20c7741ca9
  • t: Unix timestamp of when Stripe sent the webhook
  • v1: HMAC-SHA256 signature

Stripe computes the signature as:

HMAC-SHA256(key=WEBHOOK_SECRET, message="{timestamp}.{raw_request_body}")

The signed payload includes both the timestamp and the raw body. This is critical — if you parse the body before verification, or if any middleware reformats the JSON, the signature will not match.

The webhook secret (whsec_...) is unique per webhook endpoint and is available in the Stripe Dashboard under Developers > Webhooks > [your endpoint] > Signing secret.

Correct Implementation with stripe.constructEvent()

Stripe's official SDK handles signature verification correctly. Use it — do not reimplement the verification yourself.

Next.js App Router (Route Handler):

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
import { logger } from '@/lib/logger';
import connectDB from '@/lib/mongoose';
import Order from '@/models/Order';
import User from '@/models/User';

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

export async function POST(request: NextRequest): Promise<NextResponse> {
  if (!webhookSecret) {
    logger.error('STRIPE_WEBHOOK_SECRET not configured');
    return NextResponse.json(
      { error: 'Webhook secret not configured' },
      { status: 500 }
    );
  }

  // Read the raw body as text — do NOT use request.json()
  // JSON.parse + JSON.stringify will reformat the body and invalidate the signature
  const rawBody = await request.text();
  const signature = request.headers.get('stripe-signature');

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing Stripe-Signature header' },
      { status: 400 }
    );
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error';
    logger.warn('Stripe webhook signature verification failed', { message });
    return NextResponse.json(
      { error: `Webhook signature verification failed: ${message}` },
      { status: 400 }
    );
  }

  // Signature verified — process the event
  try {
    await handleStripeEvent(event);
  } catch (err) {
    logger.error('Failed to process Stripe webhook', {
      eventId: event.id,
      eventType: event.type,
      error: err instanceof Error ? err.message : 'Unknown error'
    });
    // Return 500 to signal Stripe to retry
    return NextResponse.json(
      { error: 'Event processing failed' },
      { status: 500 }
    );
  }

  return NextResponse.json({ received: true });
}

// Disable body parsing middleware for this route
export const runtime = 'nodejs';

Critical detail — body parsing: The route handler above uses request.text() to get the raw body. This is essential. If you use request.json(), Node.js will parse and re-serialize the JSON, potentially changing whitespace or key ordering, invalidating the HMAC signature.

In Express:

// Use express.raw() for the webhook route, NOT express.json()
app.post(
  '/webhook/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;

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

    // Process event...
    res.json({ received: true });
  }
);

Understanding Replay Attack Prevention

A replay attack occurs when an attacker captures a legitimate webhook request and resends it later to trigger the same action again. For example: capturing a payment_intent.succeeded event and replaying it multiple times to credit an account repeatedly.

Stripe's signature mechanism includes the timestamp (t=...) specifically to mitigate replay attacks. stripe.webhooks.constructEvent() validates that the event timestamp is within a configurable tolerance window of the current time. The default tolerance is 300 seconds (5 minutes). If the timestamp is older than 5 minutes, verification fails with:

Error: Webhook Error: Timestamp outside the tolerance zone

You can adjust this tolerance:

// Allow up to 30 seconds of clock skew
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret, 30);

// Disable timestamp validation entirely (NOT recommended for production)
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret, -1);

Never disable timestamp validation in production. The 5-minute default is appropriate for virtually all deployments. If your servers have significant clock drift (>1 minute), fix your NTP configuration — this is a broader infrastructure problem.

Idempotent Event Processing

Stripe delivers each event at least once — due to network failures or retry logic, you may receive the same event multiple times. Your webhook handler must be idempotent: processing the same event twice must produce the same result as processing it once.

The naive, broken approach:

// BAD: Creates duplicate orders on repeated delivery
async function handlePaymentSucceeded(event: Stripe.PaymentIntent): Promise<void> {
  await Order.create({
    paymentIntentId: event.id,
    amount: event.amount,
    status: 'paid'
  });
}

Idempotent implementation using event ID:

// Good: Uses event ID as idempotency key
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
  await connectDB();

  // Check if this event has already been processed
  const existing = await ProcessedWebhook.findOne({ stripeEventId: event.id });
  if (existing) {
    logger.info('Stripe event already processed — skipping', {
      eventId: event.id,
      eventType: event.type
    });
    return;
  }

  // Process the event based on type
  switch (event.type) {
    case 'payment_intent.succeeded': {
      const paymentIntent = event.data.object as Stripe.PaymentIntent;
      await handlePaymentIntentSucceeded(paymentIntent);
      break;
    }
    case 'customer.subscription.created': {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionCreated(subscription);
      break;
    }
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionCanceled(subscription);
      break;
    }
    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailed(invoice);
      break;
    }
    default:
      logger.info('Unhandled Stripe event type', { eventType: event.type });
  }

  // Mark as processed AFTER successful handling
  await ProcessedWebhook.create({
    stripeEventId: event.id,
    eventType: event.type,
    processedAt: new Date()
  });
}

The ProcessedWebhook model stores processed event IDs. On repeated delivery, the handler detects the event has already been processed and returns early without side effects.

Race condition consideration: In high-traffic environments, two webhook deliveries of the same event may arrive simultaneously. Use a unique index on stripeEventId and handle the duplicate key error:

try {
  await ProcessedWebhook.create({
    stripeEventId: event.id,
    eventType: event.type,
    processedAt: new Date()
  });
} catch (err) {
  // MongoDB duplicate key error (code 11000) — another process already handled this
  if (err instanceof Error && 'code' in err && err.code === 11000) {
    logger.info('Concurrent duplicate event detected — skipping', { eventId: event.id });
    return;
  }
  throw err;
}

Handling the Response Code Contract

Stripe's retry behavior depends on your HTTP response code:

  • 2xx: Event received and processed. Stripe will not retry.
  • 4xx: Client error (invalid request, signature failure). Stripe will not retry (it assumes a permanent failure).
  • 5xx: Server error. Stripe will retry with exponential backoff for up to 72 hours.

This means you should return 5xx when processing fails due to a temporary condition (database unavailable, downstream service timeout) and let Stripe retry. Return 2xx only when processing is complete and durable.

try {
  await handleStripeEvent(event);
  return NextResponse.json({ received: true }); // 200 — do not retry
} catch (err) {
  if (err instanceof DatabaseConnectionError) {
    // Temporary failure — Stripe should retry
    return NextResponse.json({ error: 'Service temporarily unavailable' }, { status: 503 });
  }
  // Permanent failure — return 200 to prevent infinite retries, but log as error
  logger.error('Permanent webhook processing failure', { eventId: event.id, err });
  return NextResponse.json({ received: true }); // Prevent retry loop
}

Testing Webhook Integration

Use the Stripe CLI to test webhook handling locally without a public URL:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# In a separate terminal, trigger test events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed

The Stripe CLI generates a temporary webhook secret (displayed when you run stripe listen) that you should use in your local .env.local:

STRIPE_WEBHOOK_SECRET=whsec_test_...

Important: The test webhook secret from stripe listen is different from the production secret in your Stripe Dashboard. Never use production secrets in development environments, and never commit either to version control.

Stripe
webhooks
API security
HMAC
replay attacks
idempotency
payment security

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.