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.

More notes
  • The webhook that credits twiceA payment processor delivers every result at least once. The interesting engineering is in what happens when your side fails halfway through.
  • A ledger you can edit from a route handler is not a ledgerWhy every movement of value on the payments platform goes through a Postgres function, and what it costs to keep it that way.
  • Four rows that were not theirsHardening multi-tenant row-level security in four reversible phases, verified by impersonating a member and counting what they could see.
  • The bug inside the fixA payout race, the fix for it, the bug inside that fix, and why it is the best argument I have for one person owning the whole path.
  • The sale that arrives with no referrerA creator shares a link on Instagram, the buyer taps it, installs the app and purchases. Nothing in that chain carries the creator's name across. Here is what does.
  • The minimum that belongs to someone elseHeld commissions are released to creators once a brand's payout minimum is met. Group the money by creator, the obvious way, and one brand's minimum ends up holding another brand's money.
  • The commission that must not mint twiceA buyer pays and the platform owes a creator a commission. Between those two facts sit a colluding pair, a call that arrives twice, and a cart with three items on one payment.
  • The rule that has to be written twiceFirestore security rules do not cascade to subcollections. Forget that in one place and a single query returns every private message on the platform.
  • Thirty days in the ledgerSplitting a payment at charge time is simpler and wrong: a refund after the creator is paid is a clawback nobody enjoys. Holding the money creates a different set of problems, and each piece of machinery around the hold answers one of them.
  • Arbitrary but consistentA gym's assessment answers become rules that swap an exercise for a member before a session. Two rules can disagree about the same movement. The code says who wins, and the comment admits how.
  • The second one tells you the shapeHigh-risk merchants get dropped by processors, so the platform cannot be welded to one. The interface that makes a processor swappable could not be designed before the second one existed.