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_urlfrom the client
References
- Stripe: Create a Checkout Session (server-side)
- Stripe: Checkout Quickstart
- OWASP: Insecure Design (A04:2021)
Related Checks
- Stripe Price ID Tampering — BIL-15
- Never Fulfill on success_url — BIL-16
- Stripe Webhook Safety — BIL-02
Is your app safe? Run Free Scan →