← All advisories
VV-046criticalBroken access control2026-05

Unverified Stripe webhooks hand out free upgrades

If your Stripe webhook handler doesn’t verify the Stripe-Signature header, anyone can POST a fake “checkout.session.completed” event and your app will treat it as a real payment. Scans and audits find this in a large share of AI-built apps.

01.

What it is

A Security Scanner probe (May 2026) sent an unsigned fake checkout.session.completed event to common webhook paths on about 6,000 web apps. 1,542 returned a 2xx, meaning they accepted it. The authors note a 200 doesn’t prove the app grants anything, since some endpoints return 200 regardless, but the missing check is real. Hits included apps on Replit (121), Vercel (142) and Lovable, Bolt and others.

A developer who audits AI-built MVPs (mostly Lovable, Bolt, Cursor and Replit) reported in r/SaaS that 26 of 30 had Stripe webhooks with no signature verification, including one app that had already processed a real refund triggered by a fake payload.

Stripe’s docs: “Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records.” Verification needs the raw request body; parsed JSON breaks the signature, which is a common reason AI-written handlers drop the check.

02.

How to check

  1. Find your webhook handler and search it for constructEvent (or constructEventAsync). If it parses JSON and switches on event.type without that call, it’s forgeable.
  2. Send it an unsigned test event. A correct handler returns 400: curl -X POST https://yourapp.com/api/stripe/webhook -H "Content-Type: application/json" -d '{"type":"checkout.session.completed","data":{"object":{}}}'
  3. On Supabase, check supabase/config.toml: a webhook function with verify_jwt = false is fine, but only if it checks the Stripe signature itself.
03.

How to fix

  1. Verify every event with stripe.webhooks.constructEvent, the raw body and your endpoint’s whsec_ signing secret, and return 400 on failure (code below).
  2. Only grant access after verification, and look the user up from the verified session (for example client_reference_id) rather than from fields an attacker could set in an unsigned request.
  3. Keep the default 5-minute timestamp tolerance; don’t set it to 0.
// app/api/stripe/webhook/route.ts (Next.js App Router)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();                  // raw body, NOT req.json()
  const sig = req.headers.get('stripe-signature') ?? '';
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    if (session.payment_status === 'paid') {
      // grant access to session.client_reference_id here
    }
  }
  return new Response(null, { status: 200 });
}

// Express: app.post('/webhook', express.raw({ type: 'application/json' }), handler)
// Supabase Edge Functions (Deno): await stripe.webhooks.constructEventAsync(body, sig, secret)
04.

Sources

The weekly email

One email a week. Only what changed.

New advisories, CVEs and fixes for Lovable, Bolt, v0, Replit, Base44 and Cursor apps. No roundups of things you already read.

Free. The list is new, so the first issues go out once there are readers. Unsubscribe from any email in one click. We never sell or share addresses.