Never Fulfill on success_url
Billing Safety · BIL-16 · Priority: P0
Why It Matters
Stripe's success_url is a redirect hint, not a payment confirmation. When your app grants access, activates subscriptions, or unlocks features based on the user landing on the success URL — instead of waiting for the checkout.session.completed webhook — anyone can bypass payment entirely by navigating directly to that URL.
This is the most common billing vulnerability in AI-built apps. AI tools generate the simplest possible flow: redirect to Stripe → land on success page → grant access. They skip the webhook-based fulfillment that Stripe explicitly recommends.
Priority: P0 — Direct revenue bypass. Users get paid features without paying.
Affected Stack: Next.js + Stripe, any framework with Stripe Checkout
The Problem
AI code generators typically create a /billing/success page that reads the session_id from the URL query parameter and immediately activates the user's subscription or grants premium access.
// ❌ What AI tools typically generate
// app/billing/success/page.tsx
export default async function SuccessPage({ searchParams }) {
const { session_id } = searchParams;
// Immediately grants access based on URL parameter!
await db.users.update({
where: { id: currentUser.id },
data: { plan: 'pro', active: true },
});
return <h1>Welcome to Pro! 🎉</h1>;
}
Why this fails:
- The
session_idURL parameter can be guessed or reused - Stripe has not confirmed payment was actually collected
- The user may have had insufficient funds (payment intent pending)
- An attacker can navigate directly to
/billing/success?session_id=anything
The Fix
Fulfillment must happen exclusively in the webhook handler, after Stripe confirms payment. The success page should only display a "processing" state and poll for fulfillment status.
// ✅ Webhook handler — the ONLY place that grants access
// app/api/stripe/webhook/route.ts
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
// Verify payment status
if (session.payment_status === 'paid') {
await db.users.update({
where: { stripeCustomerId: session.customer as string },
data: { plan: session.metadata?.plan, active: true },
});
}
}
// ✅ Success page — only shows status, never activates
// app/billing/success/page.tsx
export default function SuccessPage() {
return (
<div>
<h1>Payment received!</h1>
<p>Your account is being activated. This usually takes a few seconds.</p>
{/* Poll /api/user/status or use real-time subscription */}
</div>
);
}
Key rules:
- The success page is cosmetic — it never writes to the database
- Only the webhook handler grants access, after verifying
payment_status === 'paid' - Store the plan information in
session.metadataduring checkout creation - Consider adding a brief polling mechanism on the success page to show activation
References
Related Checks
- Stripe Webhook Safety — BIL-02
- Server-Initiated Checkout — BIL-14
- Stripe Price ID Tampering — BIL-15
Is your app safe? Run Free Scan →