Stripe Webhooks Security Guide

Share

To secure Stripe webhooks, you need to: (1) verify webhook signatures using the Stripe signing secret, (2) implement idempotency to handle duplicate events safely, (3) respond quickly with 200 status before processing, (4) use the raw request body for signature verification, and (5) store the webhook endpoint secret securely. This blueprint covers webhook security patterns applicable to any backend.

Setup Time1-2 hours

TL;DR

Stripe webhooks must be verified with signature checking-anyone can POST to your webhook URL. Use the raw request body for verification, handle events idempotently, and always respond quickly (within 30 seconds). Never trust client-side payment confirmations.

Signature Verification (Node.js) Stripe

app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(req: Request) {
  const body = await req.text()  // Raw body required
  const signature = headers().get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return new Response('Invalid signature', { status: 400 })
  }

  // Process the verified event
  try {
    await handleStripeEvent(event)
  } catch (err) {
    console.error('Error processing webhook:', err)
    return new Response('Webhook handler failed', { status: 500 })
  }

  return new Response(JSON.stringify({ received: true }))
}

Idempotent Event Handling

lib/stripe-handlers.ts
async function handleStripeEvent(event: Stripe.Event) {
  // Check if we've already processed this event
  const existing = await db.webhookEvent.findUnique({
    where: { stripeEventId: event.id },
  })

  if (existing) {
    console.log(`Event ${event.id} already processed`)
    return
  }

  // Record the event before processing
  await db.webhookEvent.create({
    data: {
      stripeEventId: event.id,
      type: event.type,
      status: 'processing',
    },
  })

  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutComplete(event.data.object)
        break
      case 'customer.subscription.updated':
        await handleSubscriptionUpdate(event.data.object)
        break
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object)
        break
    }

    await db.webhookEvent.update({
      where: { stripeEventId: event.id },
      data: { status: 'completed' },
    })
  } catch (err) {
    await db.webhookEvent.update({
      where: { stripeEventId: event.id },
      data: { status: 'failed', error: err.message },
    })
    throw err
  }
}

Common Mistakes

❌ Wrong: Parsing JSON before verification
// WRONG - This breaks signature verification
const body = await req.json()  // Don't do this!
stripe.webhooks.constructEvent(JSON.stringify(body), sig, secret)

// CORRECT - Use raw body
const body = await req.text()
stripe.webhooks.constructEvent(body, sig, secret)

Use raw body for signature verification. Parsing the body as JSON then re-stringifying it changes the format, causing signature verification to fail. Always use the raw request body.

Security Checklist

Pre-Launch Checklist

Webhook signature verified

Raw body used (not parsed JSON)

Events handled idempotently

Response time under 30 seconds

Different secrets for test/live modes

Webhook endpoint uses HTTPS

Supabase + Stripe Integration Firebase + Stripe Integration NextAuth + Prisma Sessions

Check Your Webhook Security

Scan for webhook vulnerabilities.

Start Free Scan
Security Blueprints

Stripe Webhooks Security Guide