Stripe in a Lovable or Bolt app: the success page isn't proof of payment A developer has documented a common flaw in AI-built Stripe integrations: apps generated by tools like Lovable and Bolt frequently unlock paid features simply when a user visits the /success page, which anyone can do by typing the URL. The writeup recommends fulfilling orders only through a server-side webhook that verifies the Stripe-Signature header with constructEventAsync on the raw body, checks payment_status, deduplicates already-fulfilled sessions, and restricts client-side updates to paid-status columns via Supabase RLS. You asked your AI builder to "add Stripe." It worked. You paid with a test card, landed on /success , and your app unlocked the Pro stuff. Here's the problem. Many AI-built apps unlock paid features just because someone opened the success page. Anyone can type /success into the address bar. No card needed. That's not a checkout. That's an honor system. Stripe says this plainly in its own docs: you can't rely on the landing page alone, because a customer might pay and then lose their connection before the page loads. Webhooks are required Stripe's fulfillment docs https://docs.stripe.com/checkout/fulfillment . So the success page is wrong both ways. It can unlock things for people who didn't pay, and it can miss people who did. yourapp.com/success or yourapp.com/success?session id=anything . If yes, your app is giving things away. Fix it before you launch. Customer pays on Stripe Checkout ↓ Stripe sends a signed webhook checkout.session.completed to YOUR server ↓ Your server checks the signature, then checks payment status ↓ Your server marks the order paid in the database ↓ The success page just READS that status "Thanks You're all set." The browser never decides who paid. Your server does, based on a message that only Stripe can sign. In Supabase, that's an Edge Function for example stripe-webhook . Have it listen for: checkout.session.completed checkout.session.async payment succeeded for bank payments that clear later Use Stripe's library to verify the Stripe-Signature header with your webhook secret whsec ... . In a Supabase Edge Function Deno , use constructEventAsync with the raw request body: js const body = await req.text ; // raw body, not req.json const sig = req.headers.get "Stripe-Signature" ; const event = await stripe.webhooks.constructEventAsync body, sig, Deno.env.get "STRIPE WEBHOOK SECRET" , undefined, Stripe.createSubtleCryptoProvider ; If the check fails, return 400 and do nothing. Without this, anyone can send your webhook a fake "they paid" message. Heads up: Stripe can't send a Supabase login token, so this one function usually runs with JWT checks off --no-verify-jwt . That's fine only because the signature check is doing the security work. Never turn off JWT checks on your other functions to "fix" a 401. payment status A completed session isn't always a paid one. Stripe's own sample only fulfills when payment status is not unpaid . Stripe can send the same event more than once. Save the Checkout Session ID with the order and skip it if you've already fulfilled it. If your orders or profiles table has is paid , plan , or credits , make sure users can't update it themselves. With Supabase RLS, don't give users an UPDATE policy on those columns. Only the webhook using the secret key on the server should change them. Your Stripe secret key sk live ... and webhook secret belong in server env vars, never in VITE or NEXT PUBLIC variables. The publishable key pk ... is the only Stripe key the browser should see. It can show a spinner and check the order status from your database. It should never write "paid" anywhere. "Don't mark orders or plans as paid from the success page. Create a Supabase Edge Function that receives Stripe webhooks, verifies the Stripe-Signature header with constructEventAsync on the raw body, handles checkout.session.completed and checkout.session.async payment succeeded, checks payment status, and updates the order using the server-side key. Skip sessions already fulfilled. Make sure users can't update is paid, plan or credits through RLS. Show me the changes before applying them." Read what it changes before you accept it. AI tools love to "fix" errors by turning checks off. /success by hand unlocks nothing is paid / plan / credits sk key or whsec secret in front-end code Is the Stripe publishable key safe to show? Yes. pk keys are meant for the browser. The sk secret key is not. Can't I just check session id on the success page? Only if your server looks that session up with Stripe and checks it. Even then, you still need the webhook, because some customers never reach the page. I build DeployVet, an outside-in security check for AI-built sites. If you want a free outside check of your own site: https://deployvet.com/?utm source=devto https://deployvet.com/?utm source=devto