← All field notes

Business logic 14 September 2026 · 6 min read · Caleb

Checkout logic flaws scanners miss

The vulnerability classes hiding in a custom checkout that no automated scanner will flag: price tampering, coupon races, and forged payment webhooks.

A scanner is very good at telling you that a form reflects unescaped input. It is close to useless at telling you that your checkout will happily sell a $400 item for $4.

The difference is that the first is a flaw in how the code handles a string, and the second is a flaw in what the application believes. A scanner has no model of what your application is supposed to do, so it cannot notice when the application does something else. That gap is where most of the serious findings in a custom checkout live.

What follows is the set of classes worth testing on any checkout that was written rather than bought. None of them require exotic tooling. All of them require someone who understands what the flow is for.

Trusting a price that came from the client

The oldest one, and still present, usually in a checkout that grew an API halfway through its life.

The pattern: the cart page renders the price, the client posts the cart back at checkout time, and the server writes an order using the price in the request. Sometimes the price is posted openly. More often it is inside a serialised cart blob, or a base64 field, or a JSON body that nobody expects anyone to look at.

Testing it is a matter of changing the number and seeing whether the order total follows. The variants that get missed:

  • Line-item price vs. order total. The server validates the total against the sum of the lines, but takes the lines on trust. Change a line, and the total recomputes to match the lie.
  • Currency confusion. The amount is validated, the currency code is not. 4000 JPY charged where 4000 USD was intended.
  • Rounding. A per-line discount rounded half-up, applied to enough lines, moves real money. This is rarely dramatic enough to be Critical and rarely gets fixed for the same reason.

The fix is always the same and always the same amount of work: the server looks up the price. The client sends identifiers and quantities, never amounts.

Quantities that were never bounded

Quantity fields get validated for "is it a number" far more often than they get validated for "is it a number that makes sense."

Worth trying on every cart:

  • Negative quantities. A line of -1 against a second item can zero out an order total, or invert it into a refund if the payment integration is credulous enough.
  • Zero. Frequently a division somewhere downstream, and an interesting error message when it lands.
  • Very large values. Integer overflow is uncommon in modern runtimes but not extinct, and the more likely outcome — a 30-second database query on a reporting join — is its own finding.
  • Fractional. 0.5 of a physical item tells you the quantity is being parsed as a float somewhere it should not be.

Coupons: stacking, reuse, and the race

Discount logic accumulates special cases faster than any other part of a checkout, and special cases are where the contradictions hide.

Stacking. Two codes that are each valid but were never meant to combine. The check is usually "is this code valid," not "is this code valid given the other code already applied."

Reuse against the rules. A single-use code that is marked used at order completion rather than at reservation. A per-customer limit enforced on account ID while the checkout permits guest orders. A code scoped to one product that is applied at cart level.

The race. This is the one that survives a code review, because reading the code it looks correct — check the counter, apply, increment. It fails because those three steps are not atomic. Fire the redemption twenty times concurrently and a single-use code redeems five, nine, twenty times. The window is small, but it does not need to be large; it needs to be reachable, and an attacker gets to choose the concurrency.

Testing for it means issuing genuinely parallel requests, not twenty sequential ones in a loop. A sequential loop passes cleanly against code that is wide open.

Skipping a step in the state machine

A checkout is a state machine, and the flow is usually enforced by the fact that each page links to the next one. That is not enforcement, it is habit.

The question to ask at every step: what happens if I post to this endpoint without having completed the previous one?

  • Posting the shipping step directly, and getting an order that skipped payment entirely.
  • Re-posting the confirmation step for an order already in paid, moving it to paid again and triggering a second fulfilment.
  • Moving an order back to an earlier state after payment, editing the cart, and moving it forward again at the old price.

The fix is that each transition validates the state it is transitioning from, server-side, and that order objects become immutable once paid.

Payment webhooks that believe what they are told

This one is worth its own section because it is common, it is high impact, and it sits outside the part of the application most people think of as the checkout.

The payment provider calls your webhook to confirm a charge. Your handler marks the order paid. Two questions decide whether that handler is safe:

  1. Is the signature verified? Every major provider signs webhooks. Not every integration checks. An unverified handler is an unauthenticated endpoint that marks arbitrary orders paid, and it is often absent from the scope document because nobody thinks of it as part of the site.
  2. Is the amount checked against the order? A verified webhook proves the provider sent it. It does not prove the amount matches what the order was supposed to cost. Where an attacker can influence the charge amount — a provider-side flow with a client-supplied value — a genuine, correctly signed webhook for $1 marks a $400 order paid.

Replay is the third question. A webhook captured and re-sent should be rejected on its event ID, not processed twice.

Authorisation between customers

Not strictly checkout logic, but it is found in the same place and by the same method: does object access check ownership?

Order confirmation pages, invoice PDFs, order-status APIs and re-order endpoints tend to be built for the happy path where the ID in the URL belongs to the person in the session. Incrementing an order ID is the first test. The second is doing it against the API rather than the HTML page, because the two frequently have different authorisation code and only one of them was reviewed.

Where this gets expensive is invoices: name, address, email, items, and sometimes the last four digits of a card, enumerable by integer.

Why this needs a person

Every class above is defined by a mismatch between what the code does and what the business meant. No tool has access to the second half of that sentence. A scanner can tell you an endpoint accepts a parameter; only a person can tell you that the parameter should never have been accepted from that side of the connection.

That is the whole reason our testing is structured the way it is. Automation runs first, because it is fast and it clears the ground. Then a person works through the flow asking what the application believes and whether it should. Everything that reaches the report was reproduced by hand, with the request that triggered it and the fix attached — and anything that could not be reproduced does not appear in the report at all.

If you have a checkout that was written rather than bought, these are the questions worth asking of it, whether or not you ask us.

Want this run against your app?
Book a scan

Rather have it in your inbox?

We send a note when there is something worth sending. That is a handful of times a year, not a Tuesday newsletter.