Stripe price tampering: never trust the amount from the browser
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."
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:
- A tiny
amount. Stripe enforces a minimum charge that depends on the currency (check the current docs), so the attacker picks the lowest value that goes through. - A
priceIdfrom the body. The buyer can pick any active Price in your account, such as a cheaper plan or an old sale price. - A
quantityyou never planned for, like 0, a negative number or a huge one. Your own stock or credit logic may not reject them. - A
planormetadata.planvalue that your webhook trusts later. The buyer pays for the starter plan and your webhook grants pro. - A discount percent or coupon amount sent from the client, or a promo code with no redemption limit.
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.
// 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 })
}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 })
}How to fix Stripe price tampering, step by step
Five changes, in this order.
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,nicknameandactivefields, so a price ID can't be edited into a cheaper one. Store the IDs in server env vars.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 apriceIdfrom the body either.Build line_items from price IDs
Pass
line_items: [{ price, quantity }]to the Checkout Session, and usemode: "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 passprice_datayourself.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
discountsfrom a server-side lookup. Never accept a discount amount or percent from the client.Fulfill from the webhook
Stripe's Checkout lifecycle ends with a webhook that fulfills the order from the
checkout.session.completedevent. 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.completedwithpayment_statusset tounpaidand confirm later withcheckout.session.async_payment_succeeded, so handle both events. Stripe can also deliver the same event more than once, so makegrantProidempotent.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.
- Open your pricing page with DevTools open and click the buy button.
- In the Network tab, find the request to your checkout route. In Chrome, right-click it and pick Copy, then Copy as fetch.
- 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.
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 send | Server decides | |
|---|---|---|
| Price and amount | Nothing | Price ID from a fixed map |
| Product or plan | A plan key from a short list | Which price ID that key means |
| Quantity | An integer you then range-check | The allowed minimum and maximum |
| Discount | A promo code string at most | Whether it is valid and its limits |
| Who is paying | Nothing | User ID from the signed-in session |
| Paid or not | Nothing | Verified 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
- Stripe docs: How Checkout works
- Stripe API reference: Create a Checkout Session
- 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 repoLeakcade 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