Diesen Artikel auf Deutsch lesen →
security · · Streatix

Anyone Can Forge Your Stripe Webhooks. Here's the 8-Line Fix.

AI code generators ship Stripe webhook handlers with no signature check. Why it happens, what the forgery looks like, and the eight lines that close it.

Prompt Lovable, Bolt, Cursor, or any current model for a Stripe webhook handler and you get one that reads the request body, trusts whatever it says, and writes to your database. No signature check. We call it the unsigned-webhook default, and it shows up in nearly every handler we’ve seen an AI tool produce. We’ve reproduced it across reference apps built with each. Anyone who can reach /api/stripe/webhook marks an invoice paid or upgrades an account with one HTTP request and no secret. This isn’t a Stripe problem. The Stripe docs tell you to verify signatures; it’s what the model ships when the prompt doesn’t ask. The fix is eight lines.

The handler trusts the body

The generated handler reads request.body and updates your database on the strength of it. Nothing verifies the request came from Stripe.

Near-verbatim Lovable output

Here’s what Lovable generates for “handle Stripe webhook”. Bolt and Cursor produce the same thing on the same prompt, give or take a comment:

// app/api/stripe/webhook/route.ts

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function POST(request: Request) {
  const event = await request.json()

  const supabase = createClient()

  if (event.type === 'checkout.session.completed') {
    await supabase
      .from('orders')
      .update({ status: 'paid', paid_at: new Date().toISOString() })
      .eq('stripe_session_id', event.data.object.id)
  }

  if (event.type === 'customer.subscription.created') {
    await supabase
      .from('users')
      .update({ plan: 'pro', subscription_active: true })
      .eq('stripe_customer_id', event.data.object.customer)
  }

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

The handler:

  1. Accepts any POST request.
  2. Parses the body as JSON.
  3. Updates your orders/users tables based on what the JSON says.

No signature check, no shared secret, no origin verification. Anyone with the URL can flip an order to paid or upgrade a user to your pro plan. The curl command below does exactly that.

Exploiting it takes one curl command

Sign up for a free account, note your stripe_customer_id (often visible on your own profile page), then:

curl -X POST https://yourapp.com/api/stripe/webhook \
  -H "Content-Type: application/json" \
  -d '{
    "type": "customer.subscription.created",
    "data": {
      "object": {
        "customer": "cus_THE_VICTIMS_ID"
      }
    }
  }'

The handler returns 200 OK. The victim now has a pro subscription they never paid for. Swap in checkout.session.completed and any pending order gets marked paid without money changing hands.

We’ve reproduced this on reference apps built with each of these tools. Same handler, same flaw, every time.

The prompt-boundary problem

Stripe’s docs explicitly tell you to verify signatures, so this isn’t a documentation gap. It’s a prompt-boundary problem in how AI tools generate code.

Prompt “build a Stripe webhook handler that marks orders as paid” and the model builds exactly that. The prompt never says verify the request actually came from Stripe, because no founder thinks to write that, and the model optimizes for making the prompt work rather than making the result safe in production.

You see the same root cause across AI-generated code. The happy path gets built and the adversarial path gets ignored: no negative tests, no “what if an attacker sends this,” no question of whether the request can be trusted at all.

You can sometimes catch it by adding “and verify the webhook signature” to the prompt. Even then, in our audits, the verification it produces is often superficially correct but bypassable. Raw body versus parsed body is the usual mistake.

The fix is eight lines

Stripe’s docs prescribe this exact shape:

// app/api/stripe/webhook/route.ts

import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'

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

export async function POST(request: Request) {
  const body = await request.text()                          // raw body, not JSON
  const signature = request.headers.get('stripe-signature')

  if (!signature) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
  }

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  const supabase = createClient()

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    await supabase
      .from('orders')
      .update({ status: 'paid', paid_at: new Date().toISOString() })
      .eq('stripe_session_id', session.id)
  }

  if (event.type === 'customer.subscription.created') {
    const sub = event.data.object as Stripe.Subscription
    await supabase
      .from('users')
      .update({ plan: 'pro', subscription_active: true })
      .eq('stripe_customer_id', sub.customer as string)
  }

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

Three things changed:

  1. Read the raw body, not parsed JSON. stripe.webhooks.constructEvent re-hashes the body bytes to verify the signature, and JSON serialization is not stable byte-for-byte. Using request.json() here silently breaks signature verification.
  2. Check the stripe-signature header and reject if missing.
  3. Call constructEvent with the webhook secret. This is the actual verification. It throws if the signature is wrong, the timestamp is too old (replay protection), or the body doesn’t match.

You’ll also need STRIPE_WEBHOOK_SECRET in your env. Get it from your Stripe dashboard under Developers > Webhooks > your endpoint > Reveal signing secret. It starts with whsec_.

What constructEvent actually verifies

The Stripe-Signature header isn’t opaque. It carries a timestamp and one or more signatures:

Stripe-Signature: t=1737936000,v1=5257a869e7...3b5f

constructEvent pulls out t and v1, recomputes the signature itself as HMAC-SHA256(secret = whsec_..., message = "{t}.{rawBody}"), and compares it against v1 with a constant-time equality check, so a wrong guess leaks nothing through timing. Two conditions have to hold or it throws:

  • The recomputed HMAC matches v1. This is why the raw body matters. The HMAC is over the exact bytes Stripe signed; request.json() followed by re-serialization reorders keys and drops whitespace, so the bytes stop matching even when the JSON is equal.
  • t is inside the tolerance window, 300 seconds by default. That’s the replay defense: a captured request that’s resent later fails once it ages past five minutes, valid signature or not.

When verification starts failing for legitimate events in production, check the secret before anything else. A mismatched whsec_ fails every event identically. It looks alarming, and it’s the most common cause.

Verifying with the negative tests AI doesn’t write

Two tests. Both negative:

# 1. Unsigned request should return 400
curl -X POST https://yourapp.com/api/stripe/webhook \
  -H "Content-Type: application/json" \
  -d '{"type":"customer.subscription.created"}'
# Expect: 400 Missing signature

# 2. Forged signature should return 400
curl -X POST https://yourapp.com/api/stripe/webhook \
  -H "Content-Type: application/json" \
  -H "stripe-signature: t=1234567890,v1=deadbeef" \
  -d '{"type":"customer.subscription.created"}'
# Expect: 400 Invalid signature

If either returns 200, your fix isn’t deployed correctly.

For positive testing, use stripe trigger from the Stripe CLI. It generates properly signed test events:

stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger customer.subscription.created

What this fix doesn’t cover

Signature verification stops forged Stripe events. It doesn’t stop legitimate events from breaking your app: a real customer.subscription.deleted arriving twice when Stripe retries on a slow response, a checkout.session.completed for a session your database doesn’t have a record of, a webhook that arrives out of order relative to a related event. Those failure modes need idempotency, retry-safety, and an event-ordering strategy, none of which this fix touches. We name those as separate findings in the audit.

The general lesson

AI tools write the request handler. They don’t write the threat model. Every external-facing endpoint in your app needs to answer two questions:

  1. Who is allowed to call this? (auth)
  2. Can a caller fake their identity? (signature or origin verification)

If the answers aren’t somewhere in your code, an attacker is the one answering them.

If you want this for your own app

Thirty-minute audit, free. We curl the same endpoints and read the same files. List in your inbox within forty-eight hours.

Book a free audit