Guides / Stripe Checkout price tampering

last checked oct 2026

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:

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.