Stripe success page is not proof of payment
If your app unlocks paid features because someone reached the success page, anyone can type that address and get the features for free. A payment is real only when your server receives a signed message from Stripe (a webhook) and marks the order paid. The success page should only read that status.
The problem
You asked your AI builder to add Stripe. You paid with a test card, landed on /success, and the app unlocked the paid features. It looks finished.
Many AI-built apps do exactly that: they unlock things because the success page loaded. Anyone can type /success into the address bar. Stripe's documentation also says you cannot rely on the landing page alone, because a customer can pay and lose their connection before the page loads. Webhooks are required for fulfilment. So the success page can unlock things for people who did not pay, and miss people who did.
The 30-second test
- Sign in as a free user, or open a private window.
- Type your success URL by hand, such as
yourapp.com/successoryourapp.com/success?session_id=anything. - See whether anything unlocks: a Pro badge, credits, a download, an order marked paid.
If something unlocks, fix it before you launch.
How it should work
Customer pays on Stripe Checkout
-> Stripe sends a signed webhook to YOUR server
-> Your server verifies the signature
-> Your server checks payment_status
-> Your server marks the order paid in the database
-> The success page only READS that status
The browser never decides who paid. Your server does, based on a message only Stripe can sign.
The fix, step by step
1. Move "mark as paid" to a webhook
In Supabase this is an Edge Function, for example stripe-webhook. Have it handle checkout.session.completed, and checkout.session.async_payment_succeeded for payment methods that clear later.
2. Verify the signature every time
Use Stripe's library to check the Stripe-Signature header against your webhook secret (it starts with whsec_). In a Supabase Edge Function (Deno), use 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 verification fails, return 400 and do nothing. Without this, anyone can send your webhook a fake "they paid" message.
Stripe cannot send a Supabase login token, so this one function usually runs with JWT verification off. That is acceptable only because the signature check does the security work. Do not turn JWT checks off on your other functions to fix a 401 error.
3. Check payment_status
A completed session is not always a paid one. Only fulfil when payment_status shows the payment went through, as in Stripe's own fulfilment example.
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 already fulfilled it.
5. Lock the "paid" columns
If a table has is_paid, plan or credits, users must not be able to update those columns. With Supabase Row Level Security, do not give users an UPDATE policy that covers them. Only the webhook, using the secret key on the server, should change them. See Supabase RLS check for AI-built apps.
6. Keep secret keys out of the frontend
Your Stripe secret key (sk_live_...) and webhook secret belong in server-side environment variables, never in VITE_ or NEXT_PUBLIC_ variables. The publishable key (pk_...) is the only Stripe key the browser should see. See Leaked API keys in your frontend.
7. Turn the success page into a thank-you page
It can show a spinner and read the order status from your database. It should never write "paid" anywhere.
A prompt you can paste into your builder
Do not 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 that were already fulfilled. Make sure users cannot 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 sometimes fix an error by turning a check off.
Quick checklist
- Typing
/successby hand unlocks nothing. - The webhook verifies the Stripe signature using the raw body.
- The webhook checks
payment_status. - Receiving the same event twice does not credit twice.
- Users cannot edit
is_paid,planorcredits. - No
sk_key orwhsec_secret appears in frontend code. - Live mode has its own webhook endpoint and secret. Test-mode ones do not carry over.
How DeployVet fits
DeployVet does not test payment flows or log in to your app, so it cannot tell you whether your success page is safe. That needs the manual test above. What the free check can do is look for Stripe secret keys in your public JavaScript and files, which is the mistake in step 6.
FAQ
Is the Stripe publishable key safe to show?
Yes. pk_ keys are meant for the browser. The sk_ secret key is not.
Can 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.
Does DeployVet test my payments?
No. It checks your public site from the outside, for things like keys in your JavaScript and exposed files. It does not test checkout or webhook logic.
Check your site for free
DeployVet checks your live site from the outside, like a stranger would. You get a score out of 100 and the most important finding with a fix.
DeployVet has three plans. The Free check is $0. A one-time Scan is $7. The Fix it pack is $10 once and adds 30 days of "Did I fix it?" re-checks and score history. For paid plans you pay first, then the scan runs.
DeployVet is an outside-in check, not an audit, a penetration test or a certification. It sees only what a stranger can reach from the public internet, and a clean result is not a guarantee. Only check sites you own or have permission to check. See the methodology for what is and is not checked.