ASAASA Standard
Active Phase 1Production Foundation

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_id URL 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.metadata during checkout creation
  • Consider adding a brief polling mechanism on the success page to show activation

References


Related Checks


Is your app safe? Run Free Scan →