Anyone Can Forge Your Stripe Webhooks. Here's the 8-Line Fix.
AI code generators consistently ship Stripe webhook handlers without signature verification. Here's why, what the bug looks like, and the exact code to fix it.
If your SaaS was built with Lovable, Bolt, Cursor, or v0, there’s a good chance an attacker can mark any invoice as paid by sending a single HTTP request to your /api/stripe/webhook endpoint. We call this pattern the unsigned-webhook default. It shows up in nearly every Stripe webhook handler we’ve seen an AI tool produce. Across Lovable, Bolt, and Cursor, with the same prompt, we’ve reproduced it consistently in our reference apps. It’s not a Stripe problem. It’s a default that AI code generators ship, and the fix is eight lines.
The handler trusts the body
The webhook handler reads request.body, trusts whatever it says, and updates your database. There is no verification that the request actually came from Stripe.
Near-verbatim Lovable output
Below is what Lovable generates for “handle Stripe webhook”, and what Bolt and Cursor produce with the same prompt, near-identically:
// 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 })
}
Read it carefully. The handler:
- Accepts any POST request.
- Parses the body as JSON.
- Updates your orders/users tables based on what the JSON says.
There is no signature check, no shared secret, no origin verification. Anyone, including the curl command we’re about to write, can flip any order to paid or upgrade any user to your pro plan.
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"
}
}
}'
Your handler returns 200 OK. The victim now has a pro subscription they never paid for. Or, with checkout.session.completed, any pending order gets marked paid without money changing hands.
We’ve reproduced this on our own reference apps built with each of these AI tools. The same handler, same flaw, every time.
The prompt-boundary problem
This isn’t a Stripe documentation problem. Stripe’s docs explicitly tell you to verify signatures. It’s a prompt-boundary problem in how AI tools generate code.
When a founder prompts “build a Stripe webhook handler that marks orders as paid,” the AI generates exactly that: a handler, a body parser, a database update. The prompt doesn’t say “verify the request actually came from Stripe” because no founder thinks to write that. The AI optimizes for “make the prompt work” rather than “make this safe in production.”
The same root cause shows up everywhere in AI-generated code: the happy path gets built; the adversarial path gets ignored. No negative tests, no “what if an attacker sends this,” no questioning of trust. Whatever the prompt asked for, ships.
You can sometimes catch it by prompting with “and verify the webhook signature”, but in our audits even that often produces verification code that’s superficially correct but bypassable (raw body vs parsed body mistakes being the most common).
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:
- Read the raw body, not parsed JSON.
stripe.webhooks.constructEventre-hashes the body bytes to verify the signature, and JSON serialization is not stable byte-for-byte. Usingrequest.json()here silently breaks signature verification. - Check the
stripe-signatureheader and reject if missing. - Call
constructEventwith 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_.
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:
- Who is allowed to call this? (auth)
- 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’re not sure which of your endpoints are missing this check, we run free thirty-minute audits where we curl every webhook handler in your app and tell you which ones are forgeable. No pitch. You get the list, you decide.