{"slug": "stripe-in-a-lovable-or-bolt-app-the-success-page-isn-t-proof-of-payment", "title": "Stripe in a Lovable or Bolt app: the success page isn't proof of payment", "summary": "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.", "body_md": "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.\n\nHere'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.\n\nStripe 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.\n\n`yourapp.com/success` or `yourapp.com/success?session_id=anything`.\nIf yes, your app is giving things away. Fix it before you launch.\n\n```\nCustomer pays on Stripe Checkout\n        ↓\nStripe sends a signed webhook (checkout.session.completed) to YOUR server\n        ↓\nYour server checks the signature, then checks payment_status\n        ↓\nYour server marks the order paid in the database\n        ↓\nThe success page just READS that status (\"Thanks! You're all set.\")\n```\n\nThe browser never decides who paid. Your server does, based on a message that only Stripe can sign.\n\nIn Supabase, that's an Edge Function (for example `stripe-webhook`). Have it listen for:\n\n`checkout.session.completed`` checkout.session.async_payment_succeeded` (for bank payments that clear later)\nUse 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:\n\n``` js\nconst body = await req.text(); // raw body, not req.json()\nconst sig = req.headers.get(\"Stripe-Signature\")!;\nconst event = await stripe.webhooks.constructEventAsync(\n  body, sig, Deno.env.get(\"STRIPE_WEBHOOK_SECRET\")!,\n  undefined, Stripe.createSubtleCryptoProvider()\n);\n```\n\nIf the check fails, return 400 and do nothing. Without this, anyone can send your webhook a fake \"they paid\" message.\n\nHeads 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.\n\n`payment_status`\nA completed session isn't always a paid one. Stripe's own sample only fulfills when `payment_status` is not `unpaid`.\n\nStripe 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.\n\nIf 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.\n\nYour 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.\n\nIt can show a spinner and check the order status from your database. It should never write \"paid\" anywhere.\n\n\"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.\"\n\nRead what it changes before you accept it. AI tools love to \"fix\" errors by turning checks off.\n\n`/success` by hand unlocks nothing`is_paid` / `plan` / `credits`\n`sk_` key or `whsec_` secret in front-end code\n**Is the Stripe publishable key safe to show?**\n\nYes. `pk_` keys are meant for the browser. The `sk_` secret key is not.\n\n**Can't I just check `session_id` on the success page?**\n\nOnly 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.\n\nI 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)", "url": "https://wpnews.pro/news/stripe-in-a-lovable-or-bolt-app-the-success-page-isn-t-proof-of-payment", "canonical_source": "https://dev.to/selfhostcalc/stripe-in-a-lovable-or-bolt-app-the-success-page-isnt-proof-of-payment-103b", "published_at": "2026-10-29 23:36:31+00:00", "updated_at": "2026-10-29 23:49:02.868990+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "developer-tools"], "entities": ["Stripe", "Supabase", "Lovable", "Bolt", "Deno"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/stripe-in-a-lovable-or-bolt-app-the-success-page-isn-t-proof-of-payment", "markdown": "https://wpnews.pro/news/stripe-in-a-lovable-or-bolt-app-the-success-page-isn-t-proof-of-payment.md", "text": "https://wpnews.pro/news/stripe-in-a-lovable-or-bolt-app-the-success-page-isn-t-proof-of-payment.txt", "jsonld": "https://wpnews.pro/news/stripe-in-a-lovable-or-bolt-app-the-success-page-isn-t-proof-of-payment.jsonld"}}