The number that ducked every threshold
A deposit arrives from a browser carrying two numbers. The platform scored one of them and charged the other, and every limit it had was measuring the wrong figure.
4 min readGoldVault · Checkout stage
On the payments platform, a deposit begins in a browser and ends as a charge on a card. Between those two events the request passes a guard: identity, a blocklist, velocity limits, a name match, and a risk score assembled from what the earlier checks found. The guard runs before anything is asked of the processor, so a request that fails it never becomes money.
The request arrives carrying two amounts. One is the total the card will be charged. The other is the base figure underneath it, before the amount added on top. The browser computes both and sends both.
Scoring one number and charging the other
The first version of the endpoint took the base figure when it was present and fell back to the total only when it was absent. Every limit the guard owned then measured the base. The card was charged the total. The two are not the same number, and the gap between them is exactly the room an attacker needs: send a base small enough to sit under every threshold, and the charge that actually lands is larger than anything the guard agreed to.
Nothing failed while that was true. No alarm exists for a limit that is being evaluated correctly against the wrong input. The checks passed, the charges settled, and the numbers in the dashboard were the numbers the guard had approved, which is the most comfortable way to be wrong.
The fix is one line and a comment
The endpoint now hands the guard the figure that becomes the card charge, and the comment above it says why: the declared split must not be the thing evaluated, because a small one ducks the thresholds while the card is charged the full total. That landed on 5 July 2026. The base figure is still accepted by the schema and still passed down to the processor adapter, where nothing reads it. It is dead weight now, kept only because removing a field from a request shape is a separate change with its own risk.
The part that is still uncomfortable
It would be tidy to say the lesson is never trust the client. That is not what happened here and it is not what the fix does. The total is also supplied by the browser. The server does not recompute it, does not derive it from the base, and does not assert that the two agree. What makes it safe to score is narrower than trust: it is the same number that gets charged, so understating it understates the charge too. Nobody can be charged more than they were scored on, because there is only one number doing both jobs.
That is an invariant, and nothing in the code asserts it. It holds because one value is used for both purposes, and it would stop holding the moment somebody introduced a second path where the charge is computed from something else. The honest version of this decision is not that client input is untrusted. It is that the guard and the charge must read the same field, and that the design is safe exactly as long as they do.
What distrust actually looks like here
Elsewhere the platform does treat browser values as hostile, and the useful pattern is that omission is never rewarded. A device fingerprint that does not match the expected shape is discarded rather than stored, so a malformed one cannot pollute the graph that correlates customers by device. Leaving the fingerprint out entirely is not a clean result either, because the absence is itself a signal the risk score counts against the request. Otherwise dropping a field would be a free way to look unremarkable. The address a request arrives from is weighted more heavily than the fingerprint for the same reason: it is observed by the server rather than offered by the client, so it cannot be rotated away as cheaply.
The same instinct runs through the rest of the path. A phone number is re-read from the verification record rather than taken from the request that claims it. When the processor reports a result, the handler binds the report to a checkout the platform originated and uses its own recorded amount; a mismatch is logged and the stored value wins, and a report about a checkout the platform never started writes nothing and raises an alert instead. The sensitive columns are pinned at the database, so the records the risk model reads cannot be written by the person being scored.
Why it belongs on this page
Every check in that guard was correct. The velocity rules counted properly, the risk model weighted sensibly, the thresholds were the right thresholds. The bug was one assignment, four lines above all of it, choosing which number to hand them. You do not find that by reviewing the risk logic, because the risk logic is fine. You find it by following a single figure from the form in the browser to the charge on the card and asking, at each step, whether it is still the same number. That is a whole-path question, and it only gets asked by somebody who owns the whole path.