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:
- A
priceIdfrom the request is passed straight to Stripe, so the attacker swaps in the ID of a cheaper price (or an old test price, or a price for a different product). quantityis negative, zero, or huge.- A
discountorcouponfield is accepted from the client. - The success page grants access, so visiting
/success?plan=prounlocks Pro without paying.
☐ 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:
- The request body carries only
plan, and it's checked against an allowlist. - The amount comes from the Stripe Price, not from the request.
- The user is identified from their session on the server, not from a
userIdin the body. success_urlandcancel_urlcome from your own config, not from a header or the body, so they can't be pointed at another site.
☐ 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
- In Stripe test mode, click Buy and copy the
/api/checkoutrequest from the Network tab. - Replay it with a changed
amount,priceId,quantityoruserId. Each should be rejected or ignored. - Visit your success URL directly without paying. Nothing should unlock.
- Send a fake webhook with
curland no valid signature. It should return 400.
Related guides
- Pre-launch security checklist for AI-built apps
- How to secure Next.js API routes
- Next.js Server Actions security, since the same rule applies if checkout is a Server Action
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.