ASAASA Standard
Not in Phase 1Production Foundation

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 a price_id
  • The server looks up the real price_id from environment variables or config
  • Any unknown plan name returns a 400 error
  • Log attempts to use invalid plans for security monitoring

References


Related Checks


Is your app safe? Run Free Scan →