Payments6 min read

Never Trust the Browser for Price: Checkout Amount Tampering

A hidden field is not hidden — it is simply not drawn on screen. If your checkout sends the price from the browser, the price is whatever the customer decides. Here is how to tell whether yours does, and the server-side pattern that ends the problem.

By ShipReady

If the amount to charge travels from the browser to your server, the browser decides what you get paid. A hidden form field can be edited in developer tools before submitting; a fetch call can be paused, altered, or replayed with a different number. The fix is never to validate the incoming amount more carefully — it is to stop accepting an amount at all. Send an identifier for what is being bought, and have the server look up the price.

This is one of the oldest bugs on the web and it keeps reappearing, because the code that produces it is the code an assistant writes when you ask for a checkout. "Take the cart total and post it to /api/checkout" is a perfectly natural sentence, and it describes a vulnerable design.

What "hidden" means

The confusion is entirely in the word. type="hidden" is a rendering instruction — do not paint this on screen. It is not a security boundary, it is not encrypted, and it is not read-only. The value is in the HTML the visitor already has.

html
<!-- What you wrote -->
<form action="/api/checkout" method="post">
  <input type="hidden" name="amount" value="4900">
  <input type="hidden" name="currency" value="usd">
  <button>Pay $49.00</button>
</form>

Right-click, Inspect, double-click the value, type 1, press Enter, click Pay. That is the entire attack. It needs no tooling, no knowledge, and no particular intent — people find this by accident.

The JavaScript version is identical in substance and only slightly less obvious:

javascript
// Same problem, different shape
const res = await fetch('/api/create-payment-intent', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ amount: cartTotal, currency: 'usd' }),
})

Anyone can set a breakpoint before that call and change cartTotal, or copy the request as cURL from the Network panel and re-send it with any number they like. Nothing about the request is authenticated as coming from your own code, because there is no such thing.

Check your own checkout

  1. Load your checkout page and view the page source. Search for type="hidden" and read every field. Anything named amount, price, total, cost or similar is the pattern in this article.
  2. Open the Network panel and complete a checkout up to the point of payment. Look at the request payload your app sends to its own API.
  3. Ask one question of each field: if a customer changed this, would the server notice? Not "would they think to" — would it notice.
  4. If you use Stripe, check whether you create a PaymentIntent with an amount taken from the request body, or with a price looked up server-side.

It is worth being precise about what you have found. An amount arriving from the browser is not proof of an exploitable flaw — a server that ignores it and recomputes the total from its own catalogue is perfectly safe, and plenty do. What it proves is that the safety depends entirely on server code that nothing outside can see. That is why ShipReady rates a checkout amount built in the browser as medium rather than critical: it reports the observable fact and tells you where to look, rather than asserting a consequence it cannot verify.

The fix: identifiers in, amounts never

Stop accepting an amount. The browser sends what the customer is buying; the server decides what that costs.

javascript
// Client: says what, never how much
await fetch('/api/create-payment-intent', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ priceId: 'price_1AbCdEf', quantity: 2 }),
})
javascript
// Server: looks the real price up, ignores anything else it was sent
const { priceId, quantity } = await req.json()

const price = await stripe.prices.retrieve(priceId)
if (!price.active) throw new Error('inactive price')

const qty = Number.isInteger(quantity) && quantity > 0 && quantity <= 10 ? quantity : 1

const intent = await stripe.paymentIntents.create({
  amount: price.unit_amount * qty,
  currency: price.currency,
})

Note that quantity is still validated. Moving the price server-side closes the obvious hole and leaves the arithmetic — a negative or absurd quantity is the next thing someone tries, and a quantity of -1 against a positive price has produced real refunds on real sites.

Stripe, Paddle and Polar all hold a product catalogue on their side precisely so you can pass an identifier instead of a number. Using it is less code than the vulnerable version, not more.

The stronger option: do not build the checkout

A hosted checkout — Stripe Checkout, a Paddle overlay, a Polar link — removes the whole class of problem. You create a session server-side with line items you control, and the customer is sent to a page you do not render. There is no client-side amount to tamper with because there is no client-side amount. For most small products this is the right answer, and the reason to build a custom flow should be a specific requirement rather than a default.

The other half: fulfilment

Getting the charge right does not help if you grant access on the wrong signal. Two rules:

  • Never fulfil on a redirect to a success URL. Anyone can navigate to /success?paid=true directly. It is a page, not a proof.
  • Fulfil on a verified webhook. Your provider signs the event; verify that signature server-side before acting on it, and treat the event as the only source of truth about whether money moved.

This is also why a webhook signing secret must never reach the browser. If it does, the signature proves nothing — ShipReady reports an exposed Stripe webhook secret as its own finding.

Discount codes belong on the server too

The same principle, one step sideways. A discount table compiled into your JavaScript bundle is readable by anyone who opens the Sources panel — no guessing required — so the codes you meant for one campaign or one partner are effectively public. And if the browser is the thing applying the discount, the code is not even needed to pay less.

The browser should send only the string the customer typed. Your server, or your payment provider’s promotion-code feature, decides whether it is valid and what it is worth. ShipReady reports discount codes compiled into the bundle at high severity for the leak alone, independent of whether the discount is applied client-side.

While you are in the checkout

  • Confirm the page and its form action are both HTTPS. A payment form that posts over plain HTTP is reported as insecure payment form transport, and a mixed-content page undermines the padlock the customer is relying on.
  • Confirm you are not collecting card numbers into your own DOM. Raw card fields in your page pull you into a far heavier PCI DSS scope than using your provider’s hosted fields — ShipReady flags raw card fields in the page.
  • Confirm the site is not in test mode, which is the other way for a working-looking checkout to take no money.

How ShipReady detects this

ShipReady reads the HTML and JavaScript your checkout pages actually serve. It reports a price submitted from a hidden form field when it finds one posting to a payment endpoint, and an amount built in the browser when client-side code sends an amount-shaped value to a checkout endpoint. Each finding names the field and the endpoint it saw.

It never submits your forms and never attempts a transaction — every finding is read from the page, which means you can reproduce all of it yourself with developer tools in a few minutes. Scan your site free to check your own checkout, or see the full list of payment checks.

Frequently asked questions

Is it enough to validate the amount on the server?
Validating against a server-side catalogue is exactly right — but at that point you already know the real price, so accepting the client’s number adds nothing except a chance to get the comparison wrong. Take the identifier and skip the amount entirely.
What if I encrypt or sign the hidden field?
That works, and it is more moving parts than looking the price up. You now have a signing key, a rotation story, and a replay window to think about, in exchange for avoiding a database read.
Does using Stripe protect me from this automatically?
Only if the amount is decided on your server. Stripe charges what your server-side PaymentIntent says; if that amount came from a request body, Stripe charges the tampered value faithfully.
Would I notice if this were exploited?
In your provider’s dashboard, yes — the payment appears with the wrong amount. The catch is that it looks like a normal successful payment, so it is only obvious if someone reconciles amounts against expected prices.

Sources

Related reading

Is your site ready to ship?

ShipReady scans up to ten pages for security, AI exposure, email deliverability, SEO and launch readiness — with a paste-ready fix for each finding. Free, no signup.