Open your checkout in the browser, then open the network tab. Watch the request your page sends to create the payment. If the amount is in that request — "amount": 4900, or a price ID your JavaScript picked — then the price is coming from the browser, and the browser belongs to the customer.
Change it to 50 and send it again. If the payment session comes back priced at fifty cents, you just found the bug: your customer sets the price, and you find out at payout.
This is not an exotic attack. It is a single edited field, and it is one of the most common things an AI coding tool gets wrong, because "take the amount from the request and create the charge" is the shortest path to a working demo. It works perfectly in every test you run — because you send the honest number.
The rule is one line:
The server decides the price. The client says what it is buying, never how much it costs.
Where this bug lives (and where it doesn't)
This is a bug in checkouts you assemble on the server — most often a Stripe integration where your API route builds the session. The dangerous shape looks like this:
// vulnerable: unit_amount is whatever the browser sent
const session = await stripe.checkout.sessions.create({
line_items: [{
price_data: {
currency: "usd",
unit_amount: req.body.amount, // <- client-controlled
product_data: { name: req.body.name } // <- also client-controlled
},
quantity: req.body.quantity // <- and this
}],
mode: "payment",
success_url, cancel_url,
});
Every value that decides what you get paid came from the request body. An attacker does not need your UI; they replay that request with amount: 50.
It mostly does not apply to fully hosted checkouts that own the price themselves — Lemon Squeezy products, Stripe Payment Links, or Stripe Checkout using a pre-created Price ID set in the dashboard. There, the amount lives on the provider's side and the client never gets to state it. (This is one concrete reason to prefer a hosted checkout that sets the amount itself. Lemon Squeezy goes further — as Merchant of Record it also takes the sales-tax/VAT class of problem off your plate — but the price-integrity win here holds for any hosted checkout that owns the amount server-side.) If that is your setup, confirm the client can't swap in a different, cheaper Price ID, and move on.
The fix: the server owns the amount
The client is allowed to say "I want the Pro plan." It is not allowed to say what the Pro plan costs. Keep the prices on the server and look them up by a product key:
// fixed: the amount comes from a server-side source of truth, never the request
const PRICES = { pro: 4900, starter: 1900 }; // cents, server-owned
const amount = PRICES[req.body.plan];
if (amount == null) {
return res.status(400).json({ error: "unknown plan" });
}
const session = await stripe.checkout.sessions.create({
line_items: [{
price_data: {
currency: "usd",
unit_amount: amount, // server-derived
product_data: { name: `ShipTested ${req.body.plan}` },
},
quantity: 1, // fixed, not from the client
}],
mode: "payment",
success_url, cancel_url,
});
The cleanest version doesn't send amounts at all: pre-create the prices in Stripe and pass price: PRICE_IDS[req.body.plan] instead of price_data. Then the number lives in Stripe, and there is nothing in your code for the client to influence. Either way, the request body decides which product, and your server decides how much.
Two more values to pin while you're here: quantity (fix it, or validate it as a positive integer — don't let 0 or a negative through) and any discount or coupon (apply it server-side after checking the customer is eligible, never because the request said so).
Reproduce it with a test
You are not breaking anything — you are sending your own endpoint a dishonest request, exactly as an attacker would:
- Capture the real request. Start a checkout from your UI and copy the request your page makes to your create-session endpoint (method, headers, body).
- Replay it, tampered. Send the same request with the amount changed to
50— fifty cents. Go no lower: Stripe's minimum charge is $0.50, and a sub-minimum amount is rejected at session creation, which would mask the bug — a vulnerable endpoint errors out and looks like it passed. Use the same session/auth you already have; this is not an auth bug, it's a trust bug. - Assert on what the server built, not the HTTP status. A 200 means nothing here. Read the created session's real
amount_totalback from Stripe (or your provider). Passing: it equals the catalog price for that product —4900— no matter what you sent. Failing: the session exists at50, and that charge can actually complete, so you would find out at payout. - Positive control. An honest request still creates a correct, chargeable session. If nothing works, you've broken checkout, not proven it safe.
Assert against the provider's record of the amount, because that is what will actually be charged. A handler that echoes 4900 in its own JSON response while creating a 50-cent session at Stripe passes the wrong check.
Quantity is a lighter case worth one more replay: send quantity: 0, then -1. Stripe rejects both at creation, so that error is a backstop you get for free — but the check that matters is your own endpoint refusing them, and never trusting a client-sent quantity to compute the total, before it ever calls Stripe.
The second layer: verify the amount when the money lands
Setting the price server-side stops tampering at creation. The belt-and-suspenders check is at the other end of the money path: when the payment-succeeded webhook arrives, confirm amount_total matches what that product should cost before you grant access. It closes the gap for any path that didn't go through your clean endpoint, and it costs one comparison. (It only means anything if you already verify the webhook's signature — an unverified webhook's amount is just another number a stranger can type.)
What a passing test proves — and does not
A green test proves that this endpoint ignores a client-supplied amount and quantity for this product. It does not prove every checkout path does — a second endpoint, a subscription upgrade, a "custom amount" donation form, or a coupon route can each re-open it. It doesn't check currency substitution (paying in a weaker currency's units) or that your Price IDs are the ones you think they are.
The goal is not to certify the billing. It is to hold one falsifiable line:
No value the browser sends changes the price the server charges — verified at session creation, and again when the payment event lands.
Re-run it after any change to your checkout endpoint, your pricing, or your plan list. The demo will always look right; the test is what tells you the number is yours to set.
See the same red/green testing pattern in the reference repo
Run the broader launch-readiness checklist
Sources: Stripe Checkout sessions, Stripe minimum charge amounts, and how products and prices work.
Test this on your own app before you sell it.