ASAASA Standard
Active Phase 1Production Foundation

Stripe Checkout Must Be Server-Initiated

Billing Safety · BIL-14 · Priority: P0

Why It Matters

When a Stripe Checkout session is created on the client side, the user controls which price_id is sent to Stripe. An attacker can intercept the request and swap a $99/month plan for a $1/month plan — or even a test-mode price — and complete the checkout successfully. Your app fulfills the "purchase" because the webhook fires with checkout.session.completed.

Independent testing found that AI coding agents are "very prone to business logic vulnerabilities" — with payment logic being the second most critical failure category (Tenzai, December 2025). AI tools optimize for "it works in the demo" — not for "it's safe in production."

Priority: P0 — Any app that lets the client choose the price is vulnerable to payment manipulation.

Affected Stack: Next.js + Stripe, any framework with Stripe Checkout


The Problem

AI code generators typically create a button component that calls stripe.checkout.sessions.create() with the price ID passed from the frontend — or worse, calls the Stripe API directly from a client-side function.

// ❌ What AI tools typically generate
// Client component
const handleCheckout = async (priceId: string) => {
  const res = await fetch('/api/checkout', {
    method: 'POST',
    body: JSON.stringify({ priceId }), // User controls this!
  });
  const { url } = await res.json();
  window.location.href = url;
};

// API route — trusts client-provided priceId
export async function POST(req: Request) {
  const { priceId } = await req.json();
  const session = await stripe.checkout.sessions.create({
    line_items: [{ price: priceId, quantity: 1 }],
    // ...
  });
  return Response.json({ url: session.url });
}

An attacker can call this API with any priceId — including test prices, $0 prices, or prices from a different Stripe account.


The Fix

The server must determine the price based on a plan identifier that maps to a server-side allowlist of valid price IDs. Never accept a raw price_id from the client.

// ✅ Production-safe pattern
const PLAN_PRICES: Record<string, string> = {
  starter: process.env.STRIPE_PRICE_STARTER!,
  pro: process.env.STRIPE_PRICE_PRO!,
  enterprise: process.env.STRIPE_PRICE_ENTERPRISE!,
};

export async function POST(req: Request) {
  const { plan } = await req.json();

  const priceId = PLAN_PRICES[plan];
  if (!priceId) {
    return new Response('Invalid plan', { status: 400 });
  }

  // Verify user is authenticated
  const user = await getAuthenticatedUser(req);
  if (!user) {
    return new Response('Unauthorized', { status: 401 });
  }

  const session = await stripe.checkout.sessions.create({
    customer: user.stripeCustomerId,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: `${process.env.NEXT_PUBLIC_URL}/billing?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
  });

  return Response.json({ url: session.url });
}

Key rules:

  • Map plan names (not price IDs) from client to server-side price lookup
  • Validate that the plan exists in your allowlist
  • Always authenticate the user before creating a checkout session
  • Never pass success_url from the client

References


Related Checks


Is your app safe? Run Free Scan →