Users getting paid features without paying? How to secure Stripe in a Supabase app
In apps built with Lovable, Bolt and similar tools, people usually get paid features for free in one of two ways. Either the app switches on Pro when the "thank you" page loads, or the Stripe webhook accepts any message without checking Stripe's signature. The fix is the same for both: only a webhook that verifies the Stripe-Signature header should change a user's plan. In a Supabase Edge Function, that means calling stripe.webhooks.constructEventAsync on the raw request body.
Mistake 1: the success page unlocks Pro
After checkout, Stripe sends the customer back to your success page, often /success. If that page's code sets is_pro = true when it loads, anyone can type the address and get Pro without paying. It also means the browser can change a user's plan, which your database rules shouldn't allow at all.
Test it (Stripe test mode): sign in as a test user, start checkout, then close it without paying. Type the success page's address into the browser. If the account turns Pro, you have this problem.
Mistake 2: the webhook doesn't check the signature
A Stripe webhook is deployed as an Edge Function with JWT verification turned off (--no-verify-jwt), because Stripe can't sign in to Supabase. That's correct, but it means anyone on the internet can call the function. The Stripe signature is the only proof that a message really came from Stripe.
Spot it: open the function (often supabase/functions/stripe-webhook/index.ts). If it reads the message with await req.json() and there's no constructEventAsync or constructEvent, anyone who finds the URL can send a fake "payment completed" event.
The fix: a webhook that verifies Stripe's signature
import Stripe from "npm:stripe@^22";
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");
const body = await req.text(); // the raw body: don't use req.json()
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature!,
Deno.env.get("STRIPE_WEBHOOK_SIGNING_SECRET")!,
undefined,
cryptoProvider,
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// 1. Record event.id in a stripe_events table; if it's already there, stop.
// 2. Then update the user's plan from the verified event.
return new Response("ok");
});
The signature is checked against the exact text Stripe sent, so read the body with req.text() before doing anything else with it.
Also check these
- Repeated events. Stripe can deliver the same event more than once. Store each
event.id(with a unique constraint) and skip events you've already handled. - Cancellations. Handle
customer.subscription.deletedandcustomer.subscription.updated, so Pro switches off when someone stops paying. - Who gets upgraded. Create the checkout session on the server and set
client_reference_id(or metadata) from the signed-in user, not from a value the browser sends. - Prices. Create checkout sessions with a Stripe Price ID on the server. Never take the amount from the browser.
- Plan columns. Users must not be able to update
is_proorplanthemselves. See step 3 of our RLS guide.
How to test the fix
- With the Stripe CLI, run
stripe trigger checkout.session.completedwhile your test endpoint is set up in Stripe test mode. The event should arrive and your function should accept it. - Send a request to your own webhook URL without a signature, for example
curl -X POST https://<your-project>.supabase.co/functions/v1/stripe-webhook -d '{}'. It should get a 400 and change nothing. - Repeat the success-page test from mistake 1. The plan shouldn't change.
Fix prompt for Lovable or Bolt
"Remove any code on the success page that changes the user's plan. Only the Stripe webhook Edge Function may change a plan, after verifying the Stripe-Signature header with constructEventAsync, the raw request body and the webhook signing secret. Store each Stripe event ID and skip events already processed. Handle customer.subscription.deleted so Pro is removed when a subscription ends. Users must not be able to update is_pro or plan from the browser."
Questions
Where do I find my Stripe webhook signing secret?
In the Stripe Dashboard, open Webhooks, choose your endpoint and reveal its signing secret. It starts with whsec_. Test mode and live mode endpoints have different secrets, so update it when you go live.
Why constructEventAsync and not constructEvent?
Supabase Edge Functions run on Deno, which uses the Web Crypto API. Web Crypto is asynchronous, so you need the async version with Stripe's SubtleCrypto provider.
Should JWT verification be off for my Stripe webhook function?
Yes. Stripe can't sign in to Supabase, so the function is deployed with JWT verification off. That means anyone can call it, which is exactly why the Stripe signature check is essential.
Is the success page useless then?
No. Use it to say thank you and to show the user's current plan, read from your database. It just shouldn't be the thing that changes the plan.