Trusting prices from the browser: how to secure your Stripe checkout
By Obscura Security · Updated · Stripe, Next.js, Lovable, Bolt
Short answer
If your frontend sends the price, amount or discount and your server uses it, anyone can edit that number and pay what they like. Send only a plan or product identifier from the browser, look up the Stripe Price ID on the server, and grant access from a verified webhook rather than from the success page redirect.
A common AI-generated checkout looks like this. The frontend works out a total and sends it to the server. The server passes that number to Stripe. It works, and it lets every buyer name their own price.
Why this happens
Early in a project the pricing logic lives in the React component: the cart total, the plan price, the discount. When you ask an AI tool to “add Stripe checkout”, the simplest change is to send those numbers to a new API route:
// Vulnerable: the server believes whatever the browser sends
const { amount } = await req.json();
await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{
price_data: {
currency: "aud",
product_data: { name: "Pro plan" },
unit_amount: amount, // the browser chose this
},
quantity: 1,
}],
success_url: "https://yourapp.com/success",
});
Stripe will happily charge 100 cents for a plan that should cost 9,900 cents. It has no idea what the plan is worth. Only your server knows that.
The same applies to quantity (a negative or huge number), discount, coupon percentages, currency, and any userId or plan field that decides who gets what.
How to check your own app
- Open your app, turn on the browser developer tools Network tab, and click your Buy or Upgrade button.
- Find the request that goes to your server. Look at what the browser sent. If you see
amount,price,total,unit_amount,discountorpercent_off, your server may be trusting it. - Search your code for
unit_amount,price_dataandamount:in the files that handle checkout, and check where each value comes from. - To confirm, use Stripe’s test mode with your own app. Right-click the request in the Network tab, copy it as a
fetchor cURL command, change the number, and send it. If the Stripe Checkout page shows your changed amount, the server trusts the browser.
The fix
1. Send an identifier, not a price
The browser sends something like { "plan": "pro" }. The server maps it to a Stripe Price ID, which you create once in the Stripe Dashboard.
// app/api/checkout/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// The only prices the browser can ask for. IDs come from the Stripe Dashboard.
const PRICES = new Map<string, string>([
["starter", process.env.STRIPE_PRICE_STARTER!],
["pro", process.env.STRIPE_PRICE_PRO!],
]);
export async function POST(req: Request) {
const user = await getUserOrNull(req); // your own session check
if (!user) return new Response("Unauthorised", { status: 401 });
const { plan } = await req.json();
const price = PRICES.get(plan);
if (!price) return new Response("Unknown plan", { status: 400 });
const session = await stripe.checkout.sessions.create({
mode: "subscription", // or "payment" for a one-off purchase
line_items: [{ price, quantity: 1 }],
client_reference_id: user.id, // from the server's session, not the request body
allow_promotion_codes: true,
success_url: `${process.env.APP_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
return Response.json({ url: session.url });
}
The Price ID decides the amount and currency, so nothing the browser sends can change them.
If the customer can pick a quantity, validate it: check it is a whole number and within a range you accept, and fall back to 1 if not.
2. Handle discounts in Stripe
Create coupons and promotion codes in the Stripe Dashboard. With allow_promotion_codes: true, Checkout shows a field for the customer to enter a code, and Stripe checks it and applies its limits (expiry, redemption count, minimum spend). Your frontend never reports a discount.
3. Compute any custom amount on the server
If prices really are dynamic, such as a cart, invoice or donation, load the items from your own database by ID and total them in the server. Take only item IDs and quantities from the browser. For a free-choice amount, enforce a minimum and a maximum.
4. Grant access from the webhook, not the redirect
Stripe sends the customer to your success_url after paying, but that page is just a URL. Anyone can open /welcome without paying, and some real customers never reach it.
Use the success page to show a thank-you message. If you want to display details, read the session on the server using the session_id and check that payment_status is paid and that client_reference_id matches the logged-in user. Unlock the product from your webhook, after verifying its signature. See Stripe webhook signature not verified.
Mistakes to avoid
- Taking
userIdfrom the request body. A buyer could pay for their own account and credit someone else’s, or the reverse. Use the ID from your server-side session. - Checking discount codes in the frontend. A check in JavaScript can be skipped. Check on the server or let Stripe do it.
- Mixing up test and live Price IDs. They are different objects in each mode. Keep both sets in environment variables, not in code.
- Sending
price_databuilt from request input. Inlineprice_datais fine when your server builds it from trusted data. It becomes the vulnerability above when the amount comes from the request. - Only testing as an honest user. Try the checkout with edited requests before you launch.
Common questions
- Can customers really change the price in my checkout?
- If your server reads the amount from the request body, yes. Anything the browser sends can be edited in the browser's developer tools or replayed with curl. Only values your server decides are trustworthy.
- How do I offer discounts safely?
- Create coupons and promotion codes in Stripe and let Checkout apply them (allow_promotion_codes: true). Stripe validates the code and enforces limits. Don't accept a discount percentage or a final total from the frontend.
- What about a pay-what-you-want or donation amount?
- The customer can choose the amount, but your server must check it against a minimum and maximum before creating the session, and must use the checked number. Stripe's Checkout also has a custom amount feature for prices set up that way.
- Is it safe to unlock the product when the user lands on my success page?
- No. Anyone can type that URL, and a paying customer can close the tab before the redirect. Unlock access from a verified Stripe webhook.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].