All checks

Client-trusted prices

Severity: HighQuick + Deep· 6 min read

What it is

A client-trusted price is a payment or order total that your server takes from the request instead of working out itself. The browser sends {"total": 49.00}, and the server charges 49.00. But the browser belongs to the user, so they can send 0.01 instead.

Think of a shop where customers write their own price tag at the register and the cashier rings up whatever it says. It works perfectly, as long as every customer is honest.

Why it matters

This is money leaving your business directly. Anyone can buy your product for a cent, apply a discount larger than the price, or check out with a negative quantity. Because the payment provider sees a valid charge, nothing looks wrong until you reconcile orders against your prices.

It also hides well. The checkout works in every test, because your own frontend always sends the correct total. The bug only appears when someone sends the request by hand.

OWASP's testing guide covers this under business logic testing, and it's a textbook case of client-side enforcement of server-side security.

How it happens

The frontend already shows the cart total, so it's tempting to send it along and charge it.

// POST /api/checkout  { items: [...], total: 49.0 }app.post("/api/checkout", async (req, res) => {  const intent = await stripe.paymentIntents.create({    amount: Math.round(req.body.total * 100),    currency: "usd",  });  res.json({ clientSecret: intent.client_secret });});

The same mistake shows up in subtler forms: a per-item price field in the cart, a discount percentage, a shipping amount, or a coupon whose value comes from the request instead of from your coupon table.

How it's abused

At a conceptual level:

  1. Add something to the cart and start checkout normally.
  2. Look at the checkout request in the browser's network tools and spot a total, price or discount field.
  3. Send the same request with a different value.
  4. Complete the payment at the new price. The provider sees a perfectly valid charge for the smaller amount.

How to fix it

Accept only what the customer is allowed to choose, usually product IDs and quantities. Load prices from your database, or use your payment provider's price objects, and compute the total on the server.

app.post("/api/checkout", async (req, res) => {  const items = parseCartItems(req.body.items); // [{ productId, quantity }]  const products = await db.product.findMany({    where: { id: { in: items.map((i) => i.productId) } },  });  const amount = items.reduce((sum, i) => {    const p = products.find((x) => x.id === i.productId);    if (!p) throw new Error("Unknown product");    return sum + p.priceCents * i.quantity;  }, 0);  const intent = await stripe.paymentIntents.create({ amount, currency: "usd" });  res.json({ clientSecret: intent.client_secret });});

Why this works: the only numbers that come from the request are quantities and IDs, and IDs only select prices you control. The amount charged is computed from your data.

More good practice:

  • Validate quantities. Whole numbers, at least 1, under a sensible maximum. Negative or fractional quantities are a common way to bend totals.
  • Look up coupons on the server and check they're valid, unexpired and unused, rather than accepting a discount value.
  • Consider hosted checkout. Stripe Checkout with Price IDs moves price handling to Stripe entirely; see Stripe's payment docs.
  • Reconcile in the webhook. When the payment succeeds, compare the amount paid with the order's expected total before fulfilling it.
  • For pay-what-you-want, such as tips or donations, enforce a minimum and maximum on the server and mark the endpoint as intended.

Common mistakes

  • Recomputing the total, then charging the request's total anyway. Make sure the computed value is the one passed to the payment call.
  • Trusting the item price, not the total. items: [{ id, price, quantity }] with price from the request has the same problem one level down.
  • Accepting a discount percentage. Accept a coupon code and look up what it's worth.
  • Fulfilling orders before checking the payment amount. The webhook should compare what was paid with what the order should cost.

Testing your fix

In a test environment, start a checkout and edit the request in your browser's developer tools, or with a tool like curl. Change any price, total or discount field. The charged amount should stay the same, or the request should be rejected. Try a quantity of 0, a negative quantity and a very large one; all should be refused.

How SetRepo detects it

Client-trusted prices are checked on every Quick and Deep scan with SetRepo's own taint rules. These trace a price, amount or total from the request into a payment call (such as a Stripe charge) or a saved order total. They cover JavaScript and TypeScript, Python, Go, Java and Kotlin, PHP, Ruby, Rust and C#. Quantities and values loaded from your database or payment provider don't trigger them.

The finding shows the file and line where the request value reaches the charge, with a fix prompt. Like other pattern findings it's usually labeled "verify" until confirmed.

Deep scans go further: the AI review of business logic reads your payment, coupon and plan code for flaws the rules can't see, such as a discount that can be applied twice.

Checklist

  • The server computes every amount it charges.
  • Requests carry product IDs and quantities, never prices or totals.
  • Quantities are validated as positive whole numbers with a maximum.
  • Coupons and discounts are looked up and validated on the server.
  • The payment webhook checks the amount paid against the expected total.
  • Any intentionally user-set amount has a server-side minimum and maximum.

FAQ

We validate the total in the frontend. Isn't that enough?

No. Requests can be sent without your frontend at all. Only checks on the server count.

We use Stripe. Doesn't Stripe prevent this?

Stripe charges the amount you ask for. If your server asks for 0.01 because the request said so, Stripe will happily charge 0.01. Using Stripe Price IDs or Checkout avoids passing raw amounts.

What about prices shown in the cart?

Showing prices in the frontend is fine. Just never send them back as the source of truth.

What about currency and tax?

Compute them on the server too. Take the currency from your product or account settings, and calculate tax from the address with your tax provider. Neither should come from the request as a number.

References

Check your app for this, free.

Start free scan