← Blog
·9 min read

Stripe Webhook Security: The Checklist Your App Needs

Stripe webhook security checklist for Next.js developers. Signature verification, replay attack prevention, idempotency, and error handling with code examples.

Rod

Founder & Developer

Stripe webhooks are where payment logic actually happens. A user subscribes, Stripe sends you a checkout.session.completed event, your handler activates their account. Sounds simple. The stripe webhook security part is where most implementations have gaps.

This post covers the checklist — every step you need to handle webhooks correctly in a Next.js app. These are the patterns we look for in scans at Data Hogo, and the ones most likely to create real vulnerabilities when skipped.


Why Stripe Webhook Security Matters

Without proper security on your webhook endpoint, an attacker can:

  • POST a fake payment_intent.succeeded event and get access to your paid features for free
  • Replay a legitimate captured webhook to double-credit a user's account
  • Cause your handler to error repeatedly, blocking real events from processing

None of these are theoretical. Stripe's webhook security documentation is clear: you must verify signatures. The rest of this checklist builds on that foundation.


Step 1: Always Verify the Signature

This is the most important step. Without it, nothing else matters.

Stripe signs every webhook event using your STRIPE_WEBHOOK_SECRET (starts with whsec_). Verify the signature before doing anything with the payload.

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});
 
export async function POST(req: Request) {
  const body = await req.text(); // IMPORTANT: raw text, not parsed JSON
  const signature = headers().get("stripe-signature");
 
  if (!signature) {
    return new Response("Missing signature", { status: 400 });
  }
 
  let event: Stripe.Event;
 
  try {
    // GOOD: constructEvent verifies the HMAC-SHA256 signature
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    // BAD would be: catching this error and continuing anyway
    console.error("Webhook signature verification failed:", err);
    return new Response("Invalid signature", { status: 400 });
  }
 
  // Only reach here if the signature is valid
  await handleEvent(event);
  return new Response("OK", { status: 200 });
}

Critical detail: You must pass the raw request body (as text) to constructEvent. If you parse it as JSON first (await req.json()), the signature verification will fail. The signature is computed against the exact bytes Stripe sent — JSON parsing and re-serialization changes them.


Step 2: Use the Raw Body

This is the most common implementation mistake we see in scans. Developers use req.json() and then wonder why constructEvent throws.

In Next.js App Router, reading the body as text is straightforward:

// GOOD: raw text body for signature verification
const body = await req.text();
 
// BAD: parsed JSON breaks signature verification
const body = await req.json(); // Don't do this

If you need to access parsed data from the event, do it after verification using event.data.object — that's already a parsed object.

In Next.js App Router, webhook routes need to opt out of body parsing if you're not already getting the raw body. The req.text() approach handles this correctly. No extra configuration required.


Step 3: Prevent Replay Attacks With Timestamp Validation

Stripe's signature includes a timestamp (t= in the stripe-signature header). constructEvent checks this timestamp against the current time and rejects requests where the timestamp is more than 5 minutes old by default.

Don't override this tolerance. If you see code like this in your codebase, remove it:

// BAD: disabling timestamp validation opens you to replay attacks
event = stripe.webhooks.constructEvent(
  body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET!,
  600 // don't increase this tolerance
);

The default 300-second (5-minute) window is sufficient for normal network conditions. If you're seeing legitimate events fail due to timing, the issue is your server clock — not the tolerance value.


Step 4: Make Your Handler Idempotent

Stripe retries failed webhook deliveries up to 3 days with exponential backoff. That means the same event might arrive multiple times. Your handler must be safe to call more than once with the same event.

The pattern: store processed event IDs and skip if already seen.

// database helper — adapt to your ORM/database
async function isEventProcessed(eventId: string): Promise<boolean> {
  const existing = await db.webhookEvents.findUnique({
    where: { stripeEventId: eventId },
  });
  return !!existing;
}
 
async function markEventProcessed(eventId: string): Promise<void> {
  await db.webhookEvents.create({
    data: {
      stripeEventId: eventId,
      processedAt: new Date(),
    },
  });
}
 
// In your event handler:
async function handleEvent(event: Stripe.Event) {
  // Check if we've already processed this event
  if (await isEventProcessed(event.id)) {
    console.log(`Skipping duplicate event: ${event.id}`);
    return; // Return early — this is fine, not an error
  }
 
  // Process the event
  await processStripeEvent(event);
 
  // Mark as processed only after successful handling
  await markEventProcessed(event.id);
}

The stripe_event_id column needs a unique constraint so you can't accidentally insert duplicates even under concurrent delivery. Create an index on it for fast lookups.


Step 5: Handle Only the Events You Need

Don't try to handle every Stripe event type. Handle exactly the ones your application cares about, return 200 for everything else, and log the ones you receive but don't handle.

async function processStripeEvent(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
      break;
 
    case "customer.subscription.updated":
      await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
      break;
 
    case "customer.subscription.deleted":
      await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
      break;
 
    case "invoice.payment_succeeded":
      await handleInvoicePaid(event.data.object as Stripe.Invoice);
      break;
 
    case "invoice.payment_failed":
      await handleInvoiceFailed(event.data.object as Stripe.Invoice);
      break;
 
    default:
      // Log unhandled events for debugging, but don't error
      console.log(`Unhandled Stripe event type: ${event.type}`);
  }
}

Returning 400 or 500 for unrecognized event types causes Stripe to retry — which clogs your webhook delivery queue and can block important events. Return 200 for everything, handle what you need.


Step 6: Return 200 Before Long Operations

Your webhook handler should acknowledge receipt quickly and process asynchronously if the handling is expensive. Stripe waits up to 30 seconds for a response. If you time out, it retries.

For fast operations (updating a database record), synchronous handling is fine. For slow operations (sending welcome emails, provisioning accounts, triggering multi-step workflows), consider queuing the work:

export async function POST(req: Request) {
  // Verify signature first (fast)
  const event = await verifyAndParseEvent(req);
  if (!event) {
    return new Response("Invalid signature", { status: 400 });
  }
 
  // Queue the work for background processing
  await queue.add("stripe-event", { eventId: event.id, type: event.type });
 
  // Return 200 immediately — Stripe is satisfied
  return new Response("OK", { status: 200 });
}

If you don't have a queue, synchronous handling with a timeout guard is acceptable for most apps. Just don't block the 200 response behind a slow external API call.


Step 7: Never Log the Webhook Secret or Payload

This sounds obvious. It shows up in real codebases anyway.

// BAD: logging secrets or sensitive payment data
console.log("Webhook received", {
  secret: process.env.STRIPE_WEBHOOK_SECRET,
  signature,
  body,
});
 
// GOOD: log only what you need for debugging
console.log("Webhook received", {
  eventType: event.type,
  eventId: event.id,
});

Payment event payloads can contain customer email addresses, last four digits of card numbers, and billing details. Logging them verbatim creates a PII exposure risk in your application logs.


Step 8: Use Separate Webhook Secrets Per Environment

Don't use your production STRIPE_WEBHOOK_SECRET in development. Use the Stripe CLI for local testing — it provides its own signing secret:

# Install Stripe CLI, then:
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI outputs a temporary webhook signing secret (starts with whsec_). Use that as STRIPE_WEBHOOK_SECRET in your .env.local. Your production secret never touches your local machine.

In Stripe's Dashboard, create separate webhook endpoints for production and staging environments. Each endpoint has its own signing secret. Never share secrets across environments.


Check Your Webhook Implementation

The patterns above — missing signature verification, using req.json() instead of req.text(), non-idempotent handlers — are exactly what automated scanners look for. We flag these in Data Hogo scans when they appear in Stripe webhook route files.

If you've scaffolded your Stripe integration with AI assistance, there's a reasonable chance at least one of these steps was skipped. A scan will tell you in minutes.

Scan your repo free →


Complete Handler Reference

Here's the complete, production-ready webhook handler incorporating everything in this checklist:

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});
 
export async function POST(req: Request) {
  const body = await req.text(); // Raw body — required for verification
  const sig = headers().get("stripe-signature");
 
  if (!sig) {
    return new Response("Missing stripe-signature header", { status: 400 });
  }
 
  let event: Stripe.Event;
 
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return new Response("Signature verification failed", { status: 400 });
  }
 
  // Idempotency check
  const alreadyProcessed = await checkIfProcessed(event.id);
  if (alreadyProcessed) {
    return new Response("Already processed", { status: 200 });
  }
 
  try {
    await handleStripeEvent(event);
    await markProcessed(event.id);
  } catch (err) {
    console.error("Webhook processing error:", { eventId: event.id, error: err });
    // Return 500 so Stripe retries — but only for processing errors, not signature errors
    return new Response("Processing error", { status: 500 });
  }
 
  return new Response("OK", { status: 200 });
}

Frequently Asked Questions

Do I need to verify Stripe webhook signatures?

Yes, always. Without signature verification, any server on the internet can POST a fake payment event to your webhook endpoint. Your app would then grant access, upgrade accounts, or mark orders as paid based on fabricated events. Stripe signs every webhook with your STRIPE_WEBHOOK_SECRET using HMAC-SHA256 — verifying that signature confirms the request came from Stripe.

What is a Stripe webhook replay attack?

A replay attack is when an attacker intercepts a legitimate Stripe webhook event and sends it again later. If your handler is idempotent, replay attacks have no effect. If it's not — for example, if receiving the same payment.succeeded event twice credits a user twice — it's a real vulnerability. Stripe's signature includes a timestamp; constructEvent rejects requests where the timestamp is more than 5 minutes old.

What is idempotency in Stripe webhooks?

Idempotency means processing the same event multiple times produces the same result as processing it once. Stripe may send the same event more than once due to retry logic. Your handler should store processed event IDs and skip re-processing if it sees the same ID again. This prevents double-charging, double-crediting, and other side effects from duplicate delivery.

How do I get my Stripe webhook secret?

Go to the Stripe Dashboard > Developers > Webhooks. Create or select your webhook endpoint. Stripe shows a signing secret that starts with whsec_. Store this as STRIPE_WEBHOOK_SECRET in your environment variables — never in your source code. For local development, use the Stripe CLI which provides a separate signing secret for local testing.

What events should my Stripe webhook handle?

At minimum: checkout.session.completed (activate subscription), customer.subscription.updated (plan changes), customer.subscription.deleted (cancellation), invoice.payment_succeeded (recurring renewal), and invoice.payment_failed (notify user). Handle only the events you need — an unhandled event that returns 200 is better than a 500 that causes Stripe to retry indefinitely.

stripewebhookssecuritynextjspayments