Payment success-page bypass: verify payments server-side
One of the most common bugs in AI-built paid apps: the success page grants access. Anyone who types the URL gets the product free. The fix is a few lines of server code.
Here's the pattern. The user clicks Buy, pays on Stripe Checkout, and Stripe redirects them to /success. The success page runs code that marks the account as paid: it sets a flag in local storage, calls an unprotected API, or writes to the database from the browser.
The problem: nothing on that page proves a payment happened. Anyone can visit /success directly, share the link, or replay the request it makes.
How to test your own app (five minutes)
- Create a fresh account that has never paid.
- Open a private window, sign in, and type your success URL by hand:
/success,/thank-you,/checkout/success, whatever your app uses. Try it with and without a?session_id=parameter, including a made-up value. - Check whether paid features are now unlocked.
- Open DevTools → Network on the success page during a real test purchase. Note any request that changes account state (for example
POST /api/upgrade). Replay it from the never-paid account. - Check local storage and cookies. If a value like
isPro=trueunlocks features, edit it on the free account and reload.
If any step unlocks paid access, you have the bypass.
The fix: the server decides, based on Stripe
Access should change only when your server has confirmed with Stripe that a payment succeeded. There are two complementary ways to do that.
1. Webhooks (the source of truth)
Stripe sends signed events to a webhook endpoint on your server. For Checkout, the usual event is checkout.session.completed. For subscriptions, also handle invoice.paid, customer.subscription.updated and customer.subscription.deleted.
- Verify the signature with the endpoint's signing secret (
whsec_…) using the raw request body. - Look up which user the session belongs to with something your server set when it created the session:
client_reference_idor metadata. Don't trust an email or ID sent from the browser. - Check the payment state. For one-time payments,
payment_statusshould bepaid. Some payment methods complete later, so handlecheckout.session.async_payment_succeededif you enable them. - Grant access in your database. Make it idempotent so a retried event doesn't double-grant.
Details and common failure modes: Stripe webhooks not working in production.
2. Server-side session check on the success page (for instant UX)
Webhooks can arrive a few seconds after the redirect. If you want the success page to show "You're in" instantly, verify on the server:
- Your success URL includes
?session_id={CHECKOUT_SESSION_ID}(Stripe fills this in). - Your server retrieves the session from Stripe with your secret key:
stripe.checkout.sessions.retrieve(id). - It confirms
payment_status === 'paid'and that the session'sclient_reference_idmatches the signed-in user. - Only then does it grant access (idempotently, using the same code path as the webhook).
Never trust session_id on its own. A real session ID from someone else's purchase, or one that's still unpaid, must not unlock anything. The checks above handle both cases.
What the success page itself may do
- Show a "Processing your payment" state.
- Poll your own API for the account's status, which only changes after one of the two server checks above.
- Nothing else that changes access.
Gate the features on the server, too
Fixing the unlock isn't enough if paid features check a client-side flag. Every paid API route or server action should look up the user's entitlement in your database on each request. Hiding a button for free users is UX, not protection. A free user can call the API directly.
If you use Supabase from the browser, paid data needs RLS policies that check entitlement, for example a join against a subscriptions table that only your webhook writes to. Make sure regular users can't write to that table themselves.
Subscription edge cases
- Cancellations and failed renewals must revoke access. Handle the subscription deleted and updated events.
- Refunds and disputes: decide whether they revoke access, and handle
charge.refundedor dispute events if so. - Test mode vs live mode use separate keys, webhook endpoints and signing secrets.
Prompt your agent safely
A prompt that works well: "Audit only. List every place where a user's paid status is set or read. For each, show whether it's server-side and whether it's derived from a verified Stripe event or session lookup. Don't change any code yet." Then fix one path at a time and rerun the five-minute test above.
Payments are checks 8.2 and 8.3 in the Last Mile kit, with prompts P25 (Stripe integration audit) and P26 (webhook hardening). See the free preview, run your URL through the free check, or get the full kit.
Related guides
- Stripe webhooks not working in production
Most “Stripe works in test, breaks in prod” reports are the same few mistakes. Fulfillment on the success page, the wrong webhook secret, or a body parser that breaks signature verification.
- Supabase RLS checklist before launch
If your vibe-coded app talks to Supabase from the browser, Row Level Security is the difference between “users see their own data” and “anyone with the anon key reads the whole table.”
- A vibe-coded app launch checklist
Your app runs on a preview URL. Friends can click around. Launch is the moment real users and real money show up. This checklist is the short version of what usually breaks first.
Get launch notes
$ subscribe --launch-notes # new checks, prompts and shipping tips for vibe coders. No spam.