Stripe Price ID Tampering
Billing Safety · BIL-15 · Priority: P0
Why It Matters
If your API accepts a price_id from the client and passes it to Stripe without validation, any user can substitute a cheaper — or free — price ID. The checkout completes successfully, Stripe fires checkout.session.completed, and your app grants access to a plan the user didn't pay for.
This is not a theoretical attack. Software has the highest chargeback-to-transaction ratio of any industry at 0.66%. When billing logic is flawed, revenue leakage compounds — and AI-generated billing code almost never validates price IDs server-side.
Priority: P0 — Direct revenue loss. Any app that accepts price IDs from the client is vulnerable.
Affected Stack: Next.js + Stripe, any framework with Stripe integration
The Problem
AI code generators pass the price_id directly from a React component to an API route. The developer sees the correct price on the pricing page and assumes it's safe — but the API never validates that the received price_id matches an expected plan.
// ❌ Client passes price_id directly
<button onClick={() => checkout('price_1234_pro_monthly')}>
Upgrade to Pro
</button>
// API route trusts the client
export async function POST(req: Request) {
const { priceId } = await req.json();
// No validation — any price_id works
const session = await stripe.checkout.sessions.create({
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
});
}
An attacker can intercept the request and replace price_1234_pro_monthly with price_0001_free_trial or any other valid Stripe price — including test-mode prices.
The Fix
Never accept raw Stripe price IDs from the client. Use a plan identifier that maps to a server-side allowlist.
// ✅ Server-side price validation
const VALID_PLANS: Record<string, { priceId: string; name: string }> = {
starter: { priceId: process.env.STRIPE_PRICE_STARTER!, name: 'Starter' },
pro: { priceId: process.env.STRIPE_PRICE_PRO!, name: 'Pro' },
};
export async function POST(req: Request) {
const { plan } = await req.json(); // "starter" or "pro" — not a price_id
const validPlan = VALID_PLANS[plan];
if (!validPlan) {
return new Response('Invalid plan', { status: 400 });
}
const session = await stripe.checkout.sessions.create({
line_items: [{ price: validPlan.priceId, quantity: 1 }],
mode: 'subscription',
// ...
});
return Response.json({ url: session.url });
}
Key rules:
- The client sends a plan name (e.g.,
"pro"), never aprice_id - The server looks up the real
price_idfrom environment variables or config - Any unknown plan name returns a 400 error
- Log attempts to use invalid plans for security monitoring
References
Related Checks
- Server-Initiated Checkout — BIL-14
- Never Fulfill on success_url — BIL-16
- Stripe Webhook Safety — BIL-02
Is your app safe? Run Free Scan →