PL-021highCode

Payment webhook doesn't verify its signature

Anyone can send your app a fake 'payment succeeded' event and get the paid plan for free.

Severity
High
Who can exploit it
Anyone who finds the webhook URL
Found by
PatchLabs Code: webhook handlers in functions and API routes
Lab proof
Not lab-proven: file and line in the report

What it is

A payment webhook tells your app that money moved: "checkout completed, give this user the Pro plan". Payment providers sign every webhook with a secret only you and they know; Stripe puts the signature in the Stripe-Signature header. PL-021 means the handler reads the event from the body and acts on it without checking that signature. Anyone who knows the URL can then post a hand-written checkout.session.completed event and upgrade any account, add credits or mark orders paid.

Webhook functions have to accept calls without a Supabase sign-in, because the provider has no user token, so they normally run with verify_jwt = false. That is correct, but it leaves the signature as the only thing between the URL and your database.

Why AI builders produce it

The generated handler parses JSON and switches on event.type, which is how simplified webhook examples look. Verifying needs the raw request body, so code that calls req.json() first can't verify at all. And Node examples use constructEvent, while in an Edge Function Stripe's library needs constructEventAsync with its Web Crypto provider; when the copied version errors, deleting the check gets the function deploying.

Example of vulnerable code

Simplified from the patterns we see in AI-built apps. Synthetic: not taken from any real app.

supabase/functions/stripe-webhook/index.ts (vulnerable)
// supabase/functions/stripe-webhook/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';

Deno.serve(async (req) => {
  const event = await req.json();  // trusted as-is: anyone can send this
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    const admin = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
    );
    await admin.from('profiles')
      .update({ plan: 'pro' })
      .eq('id', session.client_reference_id);
  }
  return new Response('ok');
});

Check it yourself

check-pl-021.sh
# Webhook handlers that never verify a signature
grep -rlE "checkout\.session\.completed|invoice\.paid|customer\.subscription" \
  supabase/functions src app pages 2>/dev/null | xargs grep -L "constructEvent"

# Send an unsigned fake event to your local function (supabase functions serve)
curl -i -X POST http://127.0.0.1:54321/functions/v1/stripe-webhook \
  -H "Content-Type: application/json" \
  -d '{"type":"checkout.session.completed","data":{"object":{"client_reference_id":"test"}}}'
# Expect 400. A 200 means anyone can fake a payment.

The fix

Read the raw body, verify it with your webhook signing secret, and only then trust the event.

supabase/functions/stripe-webhook/index.ts (fixed)
import Stripe from 'npm:stripe@17';
import { createClient } from 'npm:@supabase/supabase-js@2';

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();

Deno.serve(async (req) => {
  const signature = req.headers.get('Stripe-Signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  const body = await req.text();  // the raw body: parsing it first breaks the check
  let event: Stripe.Event;
  try {
    event = await stripe.webhooks.constructEventAsync(
      body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!,
      undefined, cryptoProvider,
    );
  } catch (_err) {
    return new Response('Invalid signature', { status: 400 });
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    const admin = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
    );
    await admin.from('profiles')
      .update({ plan: 'pro' })
      .eq('id', session.client_reference_id);
  }
  return Response.json({ received: true });
});

Store STRIPE_WEBHOOK_SECRET (it starts whsec_) with supabase secrets set. Stripe can deliver the same event more than once, so record event.id and skip events you've already handled. Lemon Squeezy and Paddle sign their webhooks too; verify with their documented method before acting.

Code findings come with the file, the line and a one-line fix in the report, for your engineer to make. PatchLabs never pushes to your repo.

How PatchLabs finds it

code scan · report excerpt
[SCAN]    stripe-webhook/index.ts:5
          event read, never verified  LEAK
[FIX]     constructEventAsync + secret
[RESCAN]  signature verified        OK
synthetic exampleReport excerpt · read from the repo, not lab-proven

PatchLabs Code looks for payment webhook handlers (Stripe, Lemon Squeezy and Paddle) in Edge Functions and API routes, and flags those that read the request body without a signature check. It never sends events to your app. The report gives the file and line where the body is read.

When the pattern is intended

Never, for payments. If a provider really doesn't sign its webhooks, treat the webhook as a hint only: fetch the payment from the provider's API with your secret key and act on what the API says.

Why it’s rated high

High: free upgrades and direct revenue loss for anyone who finds a URL that is often guessable (stripe-webhook). Not critical by default, because it doesn't expose other users' data.

Guides

Free check

Does your app have PL-021?

Paste your public repo. PatchLabs Code reads it for this and every other code finding, and the Lab rebuilds your database to test who can read what.

Free for your first appReads your public repo onlyOr run the free static RLS check

Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.