Guides · Free check · $9 fix pack

Guides

Your Stripe success page unlocks access. Anyone can open it.

A lot of AI-built checkouts work like this: Stripe redirects the buyer to /success, and that page flips the user to "pro". It looks fine in testing because you only ever reach the page by paying. But the success URL is just a URL. Anyone signed in to your app can type it, and some builds unlock even with no session_id at all.

How to tell in one minute

  1. Sign in as a free user in a private window.
  2. Open your success URL directly: /success, then /success?session_id=cs_test_fake.
  3. If either one unlocks paid features, the unlock lives on the page, not on a verified payment.

Also look at the code for the success page. If it updates the user's plan from the browser (for example a direct database update in client code), the same update can be run by anyone with the browser console open.

Why the success page can't be the unlock

Stripe says it plainly: "You can't rely on triggering fulfillment only from your checkout landing page, because it's not guaranteed customers visit that page. For example, a customer can pay successfully and then lose their internet connection before your landing page loads" (Stripe docs: Fulfill orders). So a page-only unlock fails in both directions: real buyers who close the tab get nothing, and people who never paid can get in.

The session_id in the URL isn't proof either. Stripe fills it in from the {CHECKOUT_SESSION_ID} template variable you add to success_url, which it calls "a literal string" you "must add exactly as you see it" (Stripe docs: Customize redirect behavior). The value is only useful once your server looks that session up with your secret key, and even then a real paid session ID copied from someone else would still pass unless you check whose it is.

The fix

  1. Unlock from the webhook. Handle checkout.session.completed (and checkout.session.async_payment_succeeded for delayed payment methods) and call one fulfill(sessionId) function (customer paid but no access). Verify the signature first (can someone fake a Stripe payment?).
  2. Tie the session to the user. When you create the Checkout Session, set client_reference_id to your user's ID. Stripe describes it as a reference that "can be used to reconcile the Session with your internal systems" (Stripe API: The Checkout Session object).
  3. On the success page, check on the server. Stripe does recommend running fulfillment from the landing page too, because "webhooks can sometimes be delayed": "extract the Checkout Session ID from the URL", "Run your fulfill_checkout function with the ID provided", then "Render the page after the fulfillment attempt is complete" (source). Inside fulfill, retrieve the session from the API (Stripe API: Retrieve a Checkout Session) and check payment_status, which is "one of paid, unpaid, or no_payment_required" (source). Stripe's own example fulfills only when it is not unpaid (source).
  4. Make it run once. Stripe warns the function "might be called multiple times, possibly concurrently, for the same Checkout Session" (source), since the webhook and the page will both call it. Record the session ID with a unique constraint (duplicate events guide).
  5. Keep the plan column server-only. Whatever field says "pro" should be writable only from your server code (the webhook and the success-page handler), never from the browser. On Supabase, an "update your own profile" policy usually lets users change it: check your RLS and grants.
// Creating the session (server)
const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: [{ price: PRICE_ID, quantity: 1 }],
  client_reference_id: user.id,
  success_url: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
  cancel_url: 'https://yourapp.com/pricing',
});

// Shared by the webhook and the success page (server only)
async function fulfill(sessionId, expectedUserId) {
  const s = await stripe.checkout.sessions.retrieve(sessionId);
  if (s.payment_status === 'unpaid') return false;          // not paid (yet)
  if (expectedUserId && s.client_reference_id !== expectedUserId) return false; // someone else's session
  await grantAccessOnce(s.client_reference_id, s.id);      // your helper: unique on session id
  return true;
}

On the success page, call fulfill(session_id, currentUser.id) on the server and show "Payment received, unlocking..." if it returns false. The webhook will finish the job when it arrives.

Prompt for your AI builder

Remove any code that grants paid access just because the user reached the success page. 1) Set client_reference_id to the user's ID when creating the Stripe Checkout Session, and use success_url with ?session_id={CHECKOUT_SESSION_ID}. 2) Write one server-side fulfill(sessionId) that retrieves the session with the secret key, skips it if payment_status is unpaid, and grants access to client_reference_id once per session ID. 3) Call it from the verified Stripe webhook for checkout.session.completed and checkout.session.async_payment_succeeded. 4) On the success page, call it on the server only, and only if client_reference_id matches the signed-in user. 5) Make the plan field writable only by server code.

Check it again

Repeat the private-window test: /success and /success?session_id=cs_test_fake should unlock nothing. Then do one sandbox checkout and confirm access arrives even if you close the tab before the success page loads. That proves the webhook is doing the unlocking.

Check yours in 10 seconds (free).

Paste your webhook URL. WebhookProof sends 3 harmless test requests and tells you whether Stripe can reach it and whether it rejects unsigned events. If it finds a problem, the $9 fix pack gives you the exact copy-paste fix for your stack (refundable for 14 days).

Run the free check

FAQ

Can someone get paid access by opening my Stripe success URL?

Yes, if your app grants access when that page loads. Stripe says not to rely on the landing page for fulfillment. Grant access from a verified webhook, and on the success page only after your server retrieves the Checkout Session and checks it.

Is the session_id in the success URL safe to trust?

Not by itself. Retrieve the Checkout Session with your secret key, check payment_status is not unpaid, and check client_reference_id matches the signed-in user so a copied session ID from someone else does nothing.

Should I still fulfill on the success page?

Stripe recommends it as a second path because webhooks can be delayed, but only through the same server-side, run-once fulfill function the webhook uses.

Sources

More guides

Stripe webhook not working? Check these 7 things in order · A customer paid but didn't get access. What went wrong? · No signatures found matching the expected signature for payload · Stripe webhook failing with a 307 or 308 redirect

Last reviewed 2026-11-02. WebhookProof is an independent tool, not affiliated with Stripe, Inc.. Quotes are from the linked documentation.