Guides / Stripe Checkout price tampering
Can customers change the price in your Stripe Checkout? How to check and stop it
A lot of AI-generated checkout code works like this: the pricing page sends { amount: 4900 } or a priceId to your API route, and the route passes it straight to Stripe. It works in testing, and it also lets anyone who opens the browser's dev tools pick their own price. Here's how to find out whether your app does this, and how to fix it.
The short answer
Stripe charges exactly what your server asks for. If the amount, the price ID or the product comes from the request body, a customer can change it. Stripe's own docs put it plainly: "Always decide how much to charge on the server side, a trusted environment, as opposed to the client. This prevents malicious customers from being able to choose their own prices." To fix it:
- Let the browser send only which item it wants, like a plan name or product slug, and a quantity.
- Look the price up on your server, from an allowlist of Stripe Price IDs (or your own database), and never from the request.
- Fulfill from Stripe's record of what was paid, not from your success URL or from metadata the browser supplied.
Why this is easy to miss
Your pricing page shows the right number, and the hosted Checkout page shows whatever your server sent, so everything looks correct in testing. The browser's request to your API, though, is just text the visitor controls. They can edit it in dev tools or replay it with curl, sending "amount": 50 (Stripe's minimum charge in USD is $0.50) or a priceId for a cheaper plan. Passing a Price ID instead of an amount helps less than it seems: if the route accepts any ID, a customer can swap in any active price in your Stripe account, including an old discount price or a $1 price you made while testing.
If your app then unlocks access because the customer landed on /success?plan=pro, they don't even need to pay the wrong price. They can just visit the URL.
How to check
1. Find where you create the Checkout Session or PaymentIntent
From your project folder, this lists every line that sets a price or amount for Stripe. It searches code only, so it won't print anything from your .env files:
grep -rnE "checkout\.sessions\.create|paymentIntents\.create|price_data|unit_amount|line_items" \ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' \ --exclude-dir=node_modules --exclude-dir=.next --exclude-dir=dist .
Open each match and trace where price, unit_amount, amount and quantity come from. If you can follow any of them back to req.body, await req.json(), formData or a query string without a lookup in between, that value is customer-controlled. Supabase Edge Functions live under supabase/functions/, so they're included.
2. Send your own endpoint a changed request, in test mode
Only do this against your own app, with Stripe in test mode. Open your pricing page, click buy, and find the request to your checkout route in the browser's Network tab. Then send the same request with the price changed. For example:
curl -s -X POST "https://your-app.example.com/api/checkout" \
-H "Content-Type: application/json" \
-d '{"priceId":"price_YOUR_CHEAPEST_PRICE","amount":50,"quantity":1}' \
-o /dev/null -w '%{http_code}\n'
Use whatever field names your route actually reads. If the route needs a login, use Copy as cURL on the original request instead and edit only the body. Don't paste that copied command anywhere public, since it carries your session cookie.
Then look at what Stripe actually created. With the Stripe CLI logged in to your test account, this prints only session IDs, totals and status:
stripe checkout sessions list --limit=5 \
| jq '.data[] | {id, amount_total, currency, payment_status}'
If the newest session's amount_total is the number you sent rather than your real price, customers can set their own price.
3. Check how access gets unlocked
Search for where you mark an order as paid or give someone a plan:
grep -rnE "success|session_id|payment_status|checkout\.session\.completed" \ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' \ --exclude-dir=node_modules --exclude-dir=.next --exclude-dir=dist .
It's a problem if a page grants access just because it loaded, reads the plan from the URL, or trusts metadata your checkout route copied from the request body.
How to fix it
Map the request to a price on the server
Create your products and prices in Stripe, keep their IDs on the server, and accept only a key from the browser. Here it is as a Next.js App Router route handler. The same shape works in Express or a Supabase Edge Function:
// app/api/checkout/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// The only things a customer can buy. Price IDs live on the server.
const CATALOG = {
starter: { price: process.env.STRIPE_PRICE_STARTER!, maxQty: 1 },
team: { price: process.env.STRIPE_PRICE_TEAM!, maxQty: 20 },
} as const;
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const item = CATALOG[body.plan as keyof typeof CATALOG];
if (!item) return Response.json({ error: "Unknown plan" }, { status: 400 });
const qty = Number(body.quantity ?? 1);
if (!Number.isInteger(qty) || qty < 1 || qty > item.maxQty) {
return Response.json({ error: "Bad quantity" }, { status: 400 });
}
const session = await stripe.checkout.sessions.create({
mode: "payment", // "subscription" for recurring prices
line_items: [{ price: item.price, quantity: qty }],
success_url: "https://your-app.example.com/success?session_id={CHECKOUT_SESSION_ID}",
});
return Response.json({ url: session.url });
}
Anything not in CATALOG is refused, so neither an edited amount nor a swapped Price ID gets through. If you'd rather not hard-code IDs, Stripe's lookup keys let you fetch the current price by a name like team_monthly. Just check the requested key against your own list first.
Carts and amounts that change
If you sell a cart of items, have the browser send product IDs and quantities, then load each price from your database or Stripe on the server and build line_items from that. If you use price_data, compute unit_amount on the server too. For donations or pay-what-you-want, don't accept a raw amount: Stripe Checkout supports a price with custom_unit_amount, where the customer types the amount on Stripe's page and you set the minimum and maximum. If customers can change quantity on the Checkout page, set adjustable_quantity.maximum, since the default maximum is 99.
Fulfill from Stripe, not from the browser
Stripe's fulfillment guide says to retrieve the Checkout Session with line_items expanded, check payment_status, and fulfill from those line items. Call it from your checkout.session.completed webhook, and optionally from your success page with the session_id Stripe adds to the URL:
const PLAN_BY_PRICE: Record<string, string> = {
[process.env.STRIPE_PRICE_STARTER!]: "starter",
[process.env.STRIPE_PRICE_TEAM!]: "team",
};
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items"],
});
if (session.payment_status === "unpaid") return; // not paid yet
for (const li of session.line_items?.data ?? []) {
const plan = li.price ? PLAN_BY_PRICE[li.price.id] : undefined;
if (!plan) continue; // a price you don't sell: don't grant anything
// TODO: grant `plan` to the user once per session ID (idempotent)
}
}
The function can run more than once for the same session, from the webhook and the success page, so record which session IDs you've already fulfilled. Make sure the webhook itself verifies Stripe's signature, or anyone can post a fake checkout.session.completed event to it. Our webhook signature guide covers that.
Confirm it worked
Re-run the edited curl request from step 2. You should get a 400 for an unknown plan or bad quantity, and when you send a valid plan with a made-up amount, the new session in stripe checkout sessions list should show your real price. Then load your success page with no session_id, or a made-up one, and check that it doesn't unlock anything.
next step
Check your live app for free
Our free Leak Checker looks at your deployed app the way any visitor's browser does and flags exposed secret keys, including Stripe secret keys shipped to the browser, plus public source maps and missing security headers.
Price tampering lives in your server code, so a browser-side check can't see it. The $99 Scan + Report covers your repo and live app, including payment routes, webhooks and leaked keys.
Sources
Every Stripe detail above was checked against these official pages. Last checked Oct 2026. Products change; if an API changed, the docs below are the source of truth.
- Stripe: Accept a payment ("Always decide how much to charge on the server side")
- Stripe: Create a Checkout Session on your server (line_items, price_data)
- Stripe API: Create a Checkout Session
- Stripe: Fulfill orders (retrieve with line_items expanded, check payment_status, fulfill once)
- Stripe: Manage prices (lookup keys)
- Stripe: Let customers decide what to pay (custom_unit_amount, minimum and maximum)
- Stripe: Adjustable quantities (default maximum 99)
- Stripe API: Create a PaymentIntent (amount, $0.50 USD minimum)
- Stripe API and CLI: List Checkout Sessions