LEAKCADE

Stripe price tampering: never trust the amount from the browser

Updated 7 min read

Stripe price tampering happens when your checkout route trusts an amount, price or quantity sent from the browser. A buyer opens DevTools, edits the number and pays the lowest amount Stripe accepts. The fix is to send only a plan key, let the server map it to a Stripe price ID, and grant access from a verified webhook, the signed message Stripe sends your server after a payment succeeds.

QUICK CHECKLIST

  • Send a plan key from the browser, never an amount
  • Map plan keys to Stripe price IDs on the server
  • Build line_items from price IDs, not client numbers
  • Validate quantity with a minimum and a maximum
  • Never copy client input into metadata or discounts
  • Grant access only from a verified webhook

What Stripe price tampering is and why it works

Stripe price tampering is when a buyer edits the price, amount, quantity or plan in the request your frontend sends to your server, and your server passes the edited value to Stripe. Stripe doesn't know what your product should cost. It charges whatever your server asks for.

Your checkout route creates a Checkout Session, the Stripe object that holds what the customer is buying and what it costs. If the line item numbers came from the browser, the buyer wrote their own price tag. This is Stripe checkout price manipulation.

The OWASP Business Logic Security Cheat Sheet states the rule in two sentences: "Always re-derive security-relevant values (prices, permissions, ownership) on the server. Client state is input, not truth."

Who sets the priceTwo lanes compare checkout flows. Wrong: the browser sends a tiny amount and Stripe charges it. Right: the browser sends a plan key, the server maps it to a price ID and a verified webhook grants the plan.WRONG ORDER1Browser sends atiny amount2Server forwardsit to Stripe3Stripe chargesthe tiny amount4Webhook unlocksthe full planRIGHT ORDER1Browser sendsplan: pro2Server maps planto price ID3Stripe chargesthe real price4Verified webhookgrants planWRONG ORDER1Browser sends a tiny amount2Server forwards it to Stripe3Stripe charges the tiny amount4Webhook unlocks the full planRIGHT ORDER1Browser sends plan: pro2Server maps plan to price ID3Stripe charges the real price4Verified webhook grants plan
The server picks the price. The browser only picks the plan.

How an attacker uses it

Open DevTools on your pricing page, click the buy button and look at the request in the Network tab. If the body has amount, price, unit_amount, total or quantity, the buyer can change it and send it again.

AI coding tools reach for this shape because the frontend already knows the plan price. Stripe accepts inline prices through price_data, so the demo passes and nobody tests a wrong number.

These are the common variants:

Metadata is a set of key-value pairs you attach to an object. If your server copies a request value into metadata, the webhook later reads the buyer's own words back and believes them.

The checkout route before and after

The wrong version takes amount and name from the body. The same flaw appears whenever the Stripe amount comes from the client, under any name.

The fixed version takes a plan key and a small quantity, checks both, and looks up the price ID on the server. It also sets client_reference_id, a Stripe field that tags the session with your user ID, from the signed-in user. Your sk_live_ key must stay out of NEXT_PUBLIC_ environment variables. The Zod check (Zod is a TypeScript validation library) is covered in input validation in API routes.

BEFOREWrong: amount comes from the browser
// app/api/checkout/route.ts
import Stripe from "stripe"

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const { amount, name } = await req.json() // buyer controls both
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{
      price_data: { currency: "usd", unit_amount: amount, product_data: { name } },
      quantity: 1,
    }],
    success_url: "https://example.com/success",
  })
  return Response.json({ url: session.url })
}
AFTERRight: plan key in, price ID chosen on the server
import Stripe from "stripe"
import { z } from "zod"
import { getUserId } from "@/lib/auth" // your own helper, null if signed out
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const PRICE_IDS = { starter: process.env.STRIPE_PRICE_STARTER!, pro: process.env.STRIPE_PRICE_PRO! }
const Body = z.object({
  plan: z.enum(["starter", "pro"]),
  quantity: z.number().int().min(1).max(5).default(1),
})
export async function POST(req: Request) {
  const userId = await getUserId()
  if (!userId) return new Response("Unauthorized", { status: 401 })
  const json = await req.json().catch(() => null)
  const parsed = Body.safeParse(json)
  if (!parsed.success) return new Response("Bad request", { status: 400 })
  const { plan, quantity } = parsed.data
  const session = await stripe.checkout.sessions.create({
    mode: "payment", // use "subscription" if STRIPE_PRICE_* are recurring prices
    line_items: [{ price: PRICE_IDS[plan], quantity }],
    client_reference_id: userId,
    success_url: "https://example.com/success",
  })
  return Response.json({ url: session.url })
}
Bad bodies, unknown keys and out-of-range quantities get a 400. Extra fields like amount are never read.

How to fix Stripe price tampering, step by step

Five changes, in this order.

  1. Create the prices in Stripe

    Make your products and prices in the Dashboard or through the API. After you create a Price you can only update its metadata, nickname and active fields, so a price ID can't be edited into a cheaper one. Store the IDs in server env vars.

  2. Accept only a plan key

    The browser sends plan: "pro". The server maps it to a price ID from a fixed object and returns 400 for anything else. Don't forward a priceId from the body either.

  3. Build line_items from price IDs

    Pass line_items: [{ price, quantity }] to the Checkout Session, and use mode: "subscription" when the session includes a recurring price. For dynamic totals, such as a cart from your own database, compute them on the server and pass price_data yourself.

  4. Limit quantity and discounts

    Validate quantity as an integer with a minimum and maximum. Create coupons or promotion codes in Stripe, with redemption limits, and let Checkout apply them, or pass discounts from a server-side lookup. Never accept a discount amount or percent from the client.

  5. Fulfill from the webhook

    Stripe's Checkout lifecycle ends with a webhook that fulfills the order from the checkout.session.completed event. Verify the signature first, as shown in Stripe webhook signature verification. Then read what was bought from the session's line items, not from metadata.

    Delayed payment methods such as bank debits can send checkout.session.completed with payment_status set to unpaid and confirm later with checkout.session.async_payment_succeeded, so handle both events. Stripe can also deliver the same event more than once, so make grantPro idempotent.

    const FULFILL = ["checkout.session.completed", "checkout.session.async_payment_succeeded"]
    if (FULFILL.includes(event.type)) {
      const session = event.data.object as Stripe.Checkout.Session
      const items = await stripe.checkout.sessions.listLineItems(session.id)
      // first line item only; loop over items.data for carts
      const priceId = items.data[0]?.price?.id
      if (session.payment_status === "paid" && session.client_reference_id && priceId === PRICE_IDS.pro) {
        // your function: skip session.id values already fulfilled
        await grantPro(session.client_reference_id, session.id)
      }
    }

Test it yourself

Run these checks only against your own app, in Stripe test mode with an sk_test_xxx key, on localhost or a preview deployment. Don't point them at anyone else's site.

  1. Open your pricing page with DevTools open and click the buy button.
  2. In the Network tab, find the request to your checkout route. In Chrome, right-click it and pick Copy, then Copy as fetch.
  3. Paste it into the Console, add extra fields and change the values, then run it.
// DevTools console, your own app, test mode only
await fetch("/api/checkout", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ plan: "pro", quantity: 9999, amount: 100, priceId: "price_xxx" }),
}).then((r) => r.json())

A bad result is a Stripe URL whose Checkout page shows your edited amount, a different price or a huge quantity, or a test payment that unlocks the plan at the wrong price.

A good result is a 400 for this request, because quantity 9999 is out of range. Then resend it with quantity: 1 and the extra amount and priceId fields. You should get a Stripe URL, and Checkout should show the real price for the pro plan. Pay with Stripe's test card 4242 4242 4242 4242 and confirm the plan you grant matches what you sold.

Also search your code. If a match reads its value from req.json() or form data, fix it.

grep -rnE "unit_amount|price_data|amount:|priceId" app src --include=*.ts --include=*.tsx

The same applies to checkout started from a server action, which is a public endpoint.

Fix prompt you can paste

Give this to Cursor or Claude Code.

FIX PROMPT
Audit every place this repo creates a Stripe Checkout Session, PaymentIntent or subscription. Search for stripe.checkout.sessions.create, paymentIntents.create, subscriptions.create, unit_amount, price_data, amount:, priceId, and any API route or server action that calls them.
For each one, report file and line, and say whether price, amount, currency, quantity, plan, coupon or metadata values come from req.json(), formData or other client input.
Fix it so the browser sends only a plan key and a small integer quantity. Map the key to a Stripe price ID in a server-side object, return 400 for unknown keys, validate quantity with Zod (integer, min 1, max set by the product), and build line_items from price IDs. Set client_reference_id from the signed-in user on the server, never from the request body.
Do not copy client values into metadata or accept client discount amounts.
Find the webhook handler for checkout.session.completed and any other code that grants a plan, including success_url pages and client redirects, and report file and line for each. Access must be granted only from the verified webhook, which checks payment_status is paid and reads the price ID from the session's line items. Also handle checkout.session.async_payment_succeeded and make the grant idempotent by session ID.
Finish with a list of changed files with line numbers, and explain each change in one sentence.

What the browser can send and what the server decides

Browser may sendServer decides
Price and amountNothingPrice ID from a fixed map
Product or planA plan key from a short listWhich price ID that key means
QuantityAn integer you then range-checkThe allowed minimum and maximum
DiscountA promo code string at mostWhether it is valid and its limits
Who is payingNothingUser ID from the signed-in session
Paid or notNothingVerified webhook and payment_status

Anything in the right-hand column must never come from the request.

Work through the vibe coding security checklist for the rest of your app, or run it through Leakcade.

Further reading

  1. Stripe docs: How Checkout works
  2. Stripe API reference: Create a Checkout Session
  3. Stripe docs: Manage products and prices

FAQ

How do I stop users from changing the price in Stripe Checkout?

Never read a price or amount from the request. Send a plan key from the browser, map it to a Stripe price ID on the server, and pass it in line_items when you create the Checkout Session.

Can a customer change the amount on the Stripe Checkout page itself?

No. Your server creates the Checkout Session with your secret key, so the customer's browser can't edit the line items. The exceptions are features you turn on yourself, such as adjustable quantities, promotion codes, optional items or customer chooses price. Check amount_total and the line items in your webhook.

Is it safe to send a Stripe price ID from the frontend?

A price ID isn't a secret and can't be edited to cost less. The danger is a server that accepts any ID from the client. Map a plan key to an allowed ID on the server instead.

Can I trust Stripe metadata in my webhook?

Only values your server set itself. Metadata is plain key-value data, so anything copied from the request body is the buyer's input. Read the price ID from the session's line items and the user from a client_reference_id your server set.

Is price_data safe to use in a Checkout Session?

Yes, if your server computes the amount from your own database. It is unsafe when unit_amount comes from req.json() or form data.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.