← All guides

Stop Price Tampering in Stripe Checkout (Next.js and Supabase)

Updated 2026-10-22

Short answer: never let the browser decide what a customer pays. The client should send only what they want (a product or plan ID and a quantity). Your server looks up the real Stripe Price ID or amount from a trusted list, creates the Checkout Session, and grants access only after a verified checkout.session.completed webhook. If your API route accepts amount, price or unit_amount from the request body, anyone can buy your product for one cent.

This is one of the most common payment bugs in apps built with Lovable, Bolt, v0, Cursor and Replit, because the quickest way to make a "Buy" button work is to pass the price along with the click. It works perfectly in testing, and it's wide open in production.

How price tampering works

Here's the pattern AI tools often generate:

// app/api/checkout/route.ts  (vulnerable)
export async function POST(req: Request) {
  const { name, amount } = await req.json();
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{
      price_data: {
        currency: "usd",
        product_data: { name },
        unit_amount: amount, // comes straight from the browser
      },
      quantity: 1,
    }],
    success_url: `${origin}/success`,
    cancel_url: `${origin}/pricing`,
  });
  return Response.json({ url: session.url });
}

The frontend sends { name: "Pro plan", amount: 4900 }. An attacker opens DevTools, copies the request as fetch or curl, changes amount to 50 (Stripe's minimum for USD is 50 cents), and gets a real, valid Stripe checkout for your Pro plan at a 99% discount. Stripe has no idea anything is wrong, since your server asked for that amount.

The same problem shows up in other shapes:

☐ 1. Keep a server-side catalog

Decide on the server what each product costs. The cleanest way is to create Products and Prices in the Stripe Dashboard and map your own plan keys to their Price IDs in server-only config:

// lib/plans.ts  (server only, never import from a client component)
export const PLANS = {
  starter: { priceId: process.env.STRIPE_PRICE_STARTER!, mode: "subscription" },
  pro:     { priceId: process.env.STRIPE_PRICE_PRO!,     mode: "subscription" },
  audit:   { priceId: process.env.STRIPE_PRICE_AUDIT!,   mode: "payment" },
} as const;

export type PlanKey = keyof typeof PLANS;

Price IDs aren't secret, but keeping the mapping on the server means the client can only pick from options you allow. Use plain environment variables here, not NEXT_PUBLIC_ ones, so nobody is tempted to build the session in the browser. (See the guide to NEXT_PUBLIC_ variables for why that prefix matters.)

☐ 2. Accept only a plan key from the client

// app/api/checkout/route.ts  (fixed)
import Stripe from "stripe";
import { PLANS, type PlanKey } from "@/lib/plans";
import { getUser } from "@/lib/auth";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const user = await getUser(req);
  if (!user) return new Response("Unauthorized", { status: 401 });

  const { plan } = await req.json();
  if (typeof plan !== "string" || !(plan in PLANS)) {
    return new Response("Unknown plan", { status: 400 });
  }
  const { priceId, mode } = PLANS[plan as PlanKey];

  const session = await stripe.checkout.sessions.create({
    mode,
    line_items: [{ price: priceId, quantity: 1 }],
    client_reference_id: user.id,
    metadata: { userId: user.id, plan },
    success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
  });

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

Things to notice:

☐ 3. Validate quantity if you allow it

If customers can buy seats or credits, treat quantity as untrusted input:

const qty = Number(body.quantity);
if (!Number.isInteger(qty) || qty < 1 || qty > 100) {
  return new Response("Invalid quantity", { status: 400 });
}

You can also let Stripe handle it with adjustable_quantity on the line item, which has its own minimum and maximum.

☐ 4. If you must compute a price, do it on the server

Some apps genuinely need dynamic prices (custom quotes, usage, carts). That's fine, as long as the server calculates the total from data it trusts:

const items = await db.cartItems.findMany({ where: { cartId, userId: user.id } });
const line_items = items.map((i) => ({
  price_data: {
    currency: "usd",
    product_data: { name: i.productName },
    unit_amount: i.unitPriceCents, // read from your database, not the request
  },
  quantity: i.quantity,
}));

With Supabase, that means reading the cart with the service role on the server (or with the user's token and solid RLS policies), and making sure users can't update the price column on their own cart rows. A policy that allows update on a whole row lets a user edit the price before checkout.

☐ 5. Don't trust client-supplied coupons

If you support promo codes, either set allow_promotion_codes: true and let Stripe validate them on the Checkout page, or look up the code on the server and pass a discounts entry yourself. Never pass a percentage or amount from the browser.

☐ 6. Fulfill from the webhook, not the success page

The success page is just a URL. Anyone can visit it. Grant access only when Stripe tells you a payment succeeded, through a webhook whose signature you verify:

// app/api/stripe/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature")!;
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Bad signature", { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const s = event.data.object as Stripe.Checkout.Session;
    if (s.payment_status === "paid") {
      await grantPlan(s.metadata!.userId, s.metadata!.plan);
    }
  }
  return new Response("ok");
}

For extra safety, check the session's line items or amount_total against what you expected for that plan before granting it. The full setup, including the raw body gotcha in Next.js, is in Stripe webhook signature verification in Next.js.

If you want the success page to show "You're all set", have it fetch the session by session_id on the server and read its status. Don't unlock anything based on a query parameter.

☐ 7. Keep the secret key on the server

Price tampering is bad. A leaked sk_live_ key is worse: it lets anyone issue refunds, read customers and create charges. Make sure it's never in a NEXT_PUBLIC_ or VITE_ variable, never in client code, and never committed. If you're not sure, follow how to check if your API keys leaked on GitHub.

How to test it

  1. In Stripe test mode, click Buy and copy the /api/checkout request from the Network tab.
  2. Replay it with a changed amount, priceId, quantity or userId. Each should be rejected or ignored.
  3. Visit your success URL directly without paying. Nothing should unlock.
  4. Send a fake webhook with curl and no valid signature. It should return 400.

Related guides

Check your repo

PreflightPR checks AI-built apps for unverified Stripe webhooks, leaked Stripe and Supabase keys, unprotected API routes and other launch blockers. Run a free scan on a public repo to get a launch score and a prioritized fix list, or install the PreflightPR GitHub App to check every pull request before it ships. It's an automated best-effort review and it doesn't prove your checkout prices are safe, so still run the manual tests above before you take real payments.

Check your repo in about a minute

Paste a public GitHub URL and get a free launch score.

Run a free scan

More guides