跳到正文
DEV Community· Hayden·· 3 小時前AI 評分70

Lovable 或 Bolt 應用中的 Stripe 付款:成功頁面不代表已付款

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

AI 導讀

文章指出,Lovable 或 Bolt 應用若只憑 Stripe 結帳後的 /success 頁面解鎖付費功能,任何人手動輸入網址都可能取得權限,已付款用戶也可能因頁面未載入而未獲處理。

正文

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). 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.

The 30-second test

  1. Log in as a free user (or open a private window).
  2. Type your success URL by hand, like yourapp.com/success or yourapp.com/success?session_id=anything.
  3. Did anything unlock? A Pro badge, credits, a download, an "order paid" status?

If yes, your app is giving things away. Fix it before you launch.

How it should work

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.

The fix, step by step

1. Move "mark as paid" to a webhook

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)

2. Check the signature, every time

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:

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.

3. Check payment_status

A completed session isn't always a paid one. Stripe's own sample only fulfills when payment_status is not unpaid.

4. Make it safe to run twice

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.

5. Lock the "paid" column

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.

6. Keep secret keys off the front end

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.

7. Turn the success page into a "thank you" page

It can show a spinner and check the order status from your database. It should never write "paid" anywhere.

A prompt you can paste into Lovable, Bolt or Cursor

"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.

Quick checklist

  • [ ] Typing /success by hand unlocks nothing
  • [ ] Webhook verifies the Stripe signature with the raw body
  • [ ] Webhook checks payment_status
  • [ ] Same event twice doesn't double-credit
  • [ ] Users can't edit is_paid / plan / credits
  • [ ] No sk_ key or whsec_ secret in front-end code
  • [ ] Live mode has its own webhook endpoint and secret (test ones don't carry over)

FAQ

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

來源:DEV Community · dev.to