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
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
# 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.
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
[SCAN] stripe-webhook/index.ts:5 event read, never verified LEAK [FIX] constructEventAsync + secret [RESCAN] signature verified OK
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.
Related findings
Edge function open without sign-in while using the service key
A server function holding the key that ignores every rule, with nothing checking who is calling it.
PL-022Edge function trusts a user id sent in the request
The function acts on whichever user id the request names, so any user can act as any other.
PL-011Key still in git history
You deleted the key. Every old commit still has it, and so does every clone and fork.
Guides
- Verify Stripe webhook signatures in a Supabase Edge Function
- Pre-launch security checklist for vibe-coded apps
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.
Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.