Users Bypass Payment by Bookmarking One URL.
After a successful Stripe Checkout, the customer is redirected to a success URL — something like /success?session_id=cs_live_abc123. In many AI-generated apps, the success page handler does more than display a thank-you message. It writes to the database — activating a subscription, granting credits, or marking an order as fulfilled.
The problem: the success URL is not proof of payment. It's a redirect destination. Anyone can navigate to it, bookmark it, or share it. When the success page or its handler triggers fulfillment, access is granted based on a URL visit — not a verified payment confirmation.
The vulnerability isn't the URL itself. It's that the success page drives fulfillment instead of a webhook-verified server-side path.
Stripe's own documentation is explicit about this: "Do not rely on the redirect to the success_url alone for fulfilling purchases, as [...] customers may not always reach the success_url after a successful payment." Stripe recommends webhook-based fulfillment as the reliable path.
AI tools frequently generate the success-URL pattern because it's simpler: the user pays, lands on the success page, the page calls an API to activate access. No webhook needed. The code is shorter, it works in testing, and it ships. But it doesn't verify that payment actually happened.
Who This Is For
- Founders who use Stripe Checkout and activate access on the success page
- Developers who store subscription status or mark orders as fulfilled in the success page handler
- Teams whose payment flow doesn't include webhook processing for fulfillment
- Anyone who can answer "yes" to: "If a user navigates directly to our success URL, do they get access?"
If your success page does anything beyond showing a "thank you" message — if it writes to the database, activates a subscription, or grants credits — it is functioning as an unverified fulfillment endpoint.
What Founders Experience
- The flow looks correct. Customer pays, sees the success page, gets access. Every test produces the expected result.
- The bypass is trivial. A user who has seen the success URL once — or who guesses the pattern — can navigate to it directly. If the page activates access without verifying with Stripe, the user gets access without paying.
- Sharing amplifies the problem. A paying customer shares the success URL with a friend. The friend visits the URL. If the page activates access based on the visit, the friend gets access too.
- Session IDs don't help by themselves. Some success URLs include a
session_idparameter. The app may even look up the session in Stripe. But if it grants access based on the session existing — without verifying that the session'spayment_statusis actuallypaid— async payment methods (SEPA, ACH, Boleto) can trigger access before funds clear.
What's Actually Happening
Two patterns create this vulnerability:
1. Fulfillment on the Success Page
The AI generates a success page component that runs when the user lands on it:
// /app/success/page.tsx
const session = await stripe.checkout.sessions.retrieve(sessionId);
await db.update({ userId: session.metadata.userId, plan: 'pro' });
This activates the subscription when the success page loads. But the page loads for anyone who visits the URL — including users who didn't pay, users who bookmarked it, and users who received it from someone else.
The correct approach: the success page shows a "thank you" message only. Fulfillment happens in the webhook handler, triggered by checkout.session.completed with signature verification.
2. Trusting payment_status Without Async Awareness
For async payment methods like SEPA Direct Debit, ACH, or Boleto, checkout completion does not mean settled funds. The checkout flow finishes, but the money hasn't cleared yet. The session's payment_status may be unpaid despite the checkout being complete.
This is why the display flow and the fulfillment flow must be separate. The success page can show the customer their order status. But entitlement — actual access activation — should come through a server-side fulfillment path that confirms payment has settled, not just that checkout was initiated.
What This Puts at Risk
Unpaid access. Every user who navigates to the success URL without paying gets the same access as paying customers. Unlike a database write bypass, this requires no technical knowledge — just a URL.
Revenue that never arrives. With async payment methods, access may be granted before payment clears. If the payment fails, the user has access they didn't pay for, and there may be no automated mechanism to revoke it.
Compounding with other billing gaps. If the success page is the only fulfillment mechanism (no webhook handler), then webhook-related checks — signature verification, idempotency, lifecycle handling — don't apply. The entire billing safety layer is bypassed.
How Trust Score Detects It
BIL-16: Webhook-only fulfillment. Checks that subscription activation and order fulfillment happen through verified webhook events, not through success page handlers or client-side callbacks. This is the primary defense: if fulfillment only happens in the webhook handler, success URL visits can't trigger it.
BIL-14: Server-initiated checkout. Checks that the Checkout Session is created on the server. While primarily about price manipulation, server-initiated checkout also helps ensure the fulfillment path goes through server-controlled code, not client-side handlers.
Real Incidents
$79/month Pro plan unlocked without payment (LinkedIn PoC). A security researcher demonstrated that navigating directly to the success URL, or replaying the client-side callback, was sufficient to unlock premium access. "The server assumed the payment was done. No Stripe validation. No webhook confirmation check."
StackOverflow — fulfillment from success URL. A highly-viewed question showed a developer granting order activation in the success URL redirect handler. Top answers clarified this is a security hole — fulfillment should only happen in the webhook handler.
Async payment false activation. Stripe's documentation explicitly notes that for payment methods like SEPA and Boleto, checkout.session.completed fires before funds clear. Apps that grant access on this event without checking payment_status give access for payments that may never complete.
"First real sale, payments broken." A founder tested their payment integration in test mode but set up fulfillment on the success page. On launch day, the flow worked — but there was no webhook handler as a backup. When the success page had an error, the customer paid but never received access. No webhook meant no fallback.
Detection: How to Check Your Own App
Check 1: Does the success page write to the database?
# Search for database writes in success page handlers
grep -rn "update\|insert\|create\|upsert" app/**/success --include="*.ts" --include="*.tsx"
grep -rn "update\|insert\|create\|upsert" pages/success* --include="*.ts" --include="*.tsx"
Interpretation: If the success page handler writes subscription status, plan information, or order fulfillment to the database, it's functioning as an unverified fulfillment endpoint.
Check 2: Can you get access by visiting the success URL directly?
Open an incognito browser and navigate to your success URL — either without a session ID, or with an old/expired session ID:
https://your-app.com/success
https://your-app.com/success?session_id=cs_test_expired123
Interpretation: If the page activates access, shows premium content, or writes to the database without verifying a current, paid session, the URL is a bypass path.
Check 3: Does a webhook handler exist for fulfillment?
# Search for webhook-based fulfillment
grep -r "checkout.session.completed" --include="*.ts" --include="*.tsx" --include="*.js" | grep -v node_modules
Interpretation: A webhook handler for checkout.session.completed is a good sign — but it doesn't prove the success page isn't also fulfilling in parallel. Verify that the success page handler does not write access state or fulfillment state to the database. The webhook should be the sole fulfillment mechanism.
Related Launch Risks
- Your Stripe Webhook Trusts Strangers. — Without webhook-based fulfillment, you lose both the verification layer and the reliability layer.
- Anyone Can Upgrade to Pro for Free. — Success URL bypass is one of several paths to unpaid premium access.
- They Cancelled. They Still Have Access. — Apps without webhook handlers can't process cancellations either.
- Your App Looks Ready. It Isn't Safe to Launch. — The success URL pattern works perfectly in demos and only fails with adversarial or edge-case behavior.
FAQ
What should the success page do?
Show a "thank you" message and optionally display the order details by reading from Stripe (without writing to your database). All fulfillment — subscription activation, credit granting, order marking — should happen in the webhook handler for checkout.session.completed, after signature verification.
What if we need to show something on the success page immediately?
You can retrieve the Checkout Session from Stripe on the success page to display order details. But use this for display only — don't activate access based on it. The webhook handler should be the sole mechanism for fulfillment. If the webhook hasn't fired yet by the time the user reaches the success page, show a "processing" state instead of granting access.
We include the session_id in the success URL. Doesn't that prove payment?
Not by itself. Session existence proves checkout context, not final entitlement. A session ID confirms a checkout session was created — not that it was paid. For async payment methods, the session can exist in an unpaid state. Even for card payments, verifying the session without checking payment_status === 'paid' can create false activations.
Stripe says to use success_url. Isn't that their recommended flow?
Stripe recommends success_url as a redirect destination for the customer experience. But Stripe's own documentation explicitly says not to rely on the redirect for fulfillment. The recommended fulfillment mechanism is the checkout.session.completed webhook event, with signature verification.