# Stripe in a Lovable or Bolt app: the success page isn't proof of payment

> Source: <https://dev.to/selfhostcalc/stripe-in-a-lovable-or-bolt-app-the-success-page-isnt-proof-of-payment-103b>
> Published: 2026-10-29 23:36:31+00:00

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)
