Payments
Anyone can buy your Pro plan for 50 cents by editing the checkout request
Cataloged October 24, 2026 · Lovable · Bolt · Cursor · v0 · Replit · Supabase
Symptom
The Buy or Upgrade button works, test cards go through, and real payments show up in Stripe. But the request that creates the Checkout Session carries the price from the browser, as amount, unit_amount, price, or priceId. Anyone who copies that request from DevTools and changes the number gets a real Stripe checkout for your product at whatever price they typed, and your webhook then unlocks the plan as if they paid in full.
Root cause
The fastest way to make a Buy button work is to send the price along with the click, so AI-generated checkout routes often read amount or unit_amount from the request body and pass it to stripe.checkout.sessions.create inside price_data. Stripe charges exactly what your server asks for; it has no way to know your Pro plan costs $49. The same hole appears when the route takes a priceId from the client and passes it straight through, so a caller can swap in a cheaper Price, an old one, or one from a different product, and when quantity, coupon, or discount fields come from the browser unchecked. Stripe's own guidance on updating checkout amounts says to recalculate amounts on your server and not trust client-provided prices or totals, and to enforce business rules such as maximum quantities. Stripe's Checkout guide has you reference a Price ID and quantity when creating the session, with Products and Prices defined in the Dashboard or API. A public example: frankbria/gregory-taylor-backend issue 19, opened December 30, 2025 and labeled critical, shows a Next.js checkout route that built unit_amount from cart prices stored in localStorage, so a user could set any price before paying.
Before and after
The before route charges whatever number the browser sends. The after route accepts only a plan key and looks up the Price ID on the server.
Before
const { amount, name } = await req.json();
line_items: [{ price_data: { currency: 'usd', product_data: { name }, unit_amount: amount }, quantity: 1 }]After
const { plan } = await req.json();
const priceId = PLANS[plan]?.priceId; // server-only map
if (!priceId) return new Response('Unknown plan', { status: 400 });
line_items: [{ price: priceId, quantity: 1 }]Fix prompt
In this project only. Do not test against anyone else's app or Stripe account. 1. Find every place that creates a Stripe Checkout Session, PaymentIntent, or subscription: search for checkout.sessions.create, paymentIntents.create, subscriptions.create, price_data, unit_amount, and amount in server routes, API routes, server actions, and Supabase Edge Functions. For each one, list which values come from the request body, query string, or client state. Do not print secret keys. 2. Make the server the only place prices are decided. Add a server-only catalog (for example lib/plans.ts, never imported by client code) that maps a short plan or product key to a Stripe Price ID read from server env vars without NEXT_PUBLIC_ or VITE_ prefixes. For a store, look products up by id in the database and use the stored Stripe Price ID or server-side price. 3. Change each checkout route to accept only a plan or product key and a quantity. Reject unknown keys with 400. Ignore and remove any amount, unit_amount, price, priceId, currency, coupon, or discount field from the client. Validate quantity as an integer between 1 and a sensible maximum. 4. If the app genuinely needs a custom amount (donations, tips, quotes), compute or bound it on the server: integer cents, an explicit minimum and maximum, and a fixed currency. 5. Require a signed-in user on the checkout route where purchases are tied to an account, and set client_reference_id and metadata (user id, plan key) on the server, never from the request body. 6. In the webhook that grants access, decide what to unlock from the Price ID on the completed session's line items (or the subscription's items), not from metadata or anything the client sent, and check the amount paid matches what you expect. 7. Tell me how to verify it on my own app: copy the checkout request from my own pricing page, change the amount, priceId, or quantity, resend it, and confirm the server now returns 400 or a checkout at the real price. Do not print keys, tokens, or customer data. Do not edit unrelated files.
Paste this into the coding tool that has your own project open.
2-minute self-check
These steps are for an app you own. Do not run them against someone else's project.
- On your own pricing page, open DevTools, go to Network, click Buy or Upgrade, and select the request that returns the Stripe checkout URL. If its request body includes amount, unit_amount, price, priceId, or a cart with prices, the browser is choosing the price.
- In your own project, run grep -rn "unit_amount\|price_data\|priceId" src app supabase/functions and open each match. Any value that comes from req.json(), req.body, searchParams, or a form field instead of a server-side lookup is the bug.
- In a Stripe sandbox for your own account, copy that request as fetch or curl, change the amount or priceId to a cheaper value, and resend it. If you get back a checkout URL at the new price, fix it before your next real sale.
Common questions
Price IDs aren't secret. Why can't the browser send one?
They aren't secret, but they aren't a permission either. If your route passes whatever priceId it receives to Stripe, a caller can send the ID of your cheapest plan, an old discounted price, or another product's price and still get the plan they clicked. Let the browser send a plan key like pro, and have the server map that key to the one Price ID you allow.
My webhook is signed and verified. Doesn't that protect me?
No. A verified webhook proves Stripe sent the event, and Stripe did take a real payment, just at the tampered price. Fix the checkout route so the server picks the price, and in the webhook grant access based on the Price ID on the completed session's line items, not on metadata or a plan name the client chose.