A key you did not make

A device fingerprint is a hash the browser computes and the server has to take on faith. The useful question is not whether to trust it, but what a server does once it accepts that it cannot.

4 min readGoldVault · Risk stage

On the payments platform a deposit request carries a device fingerprint: a hash the browser computes from the signals it can see about itself, sent along with the amount. The server uses it in two ways. It asks whether the same device has appeared on other customers' accounts, which is the strongest single fraud signal that platform has. And it counts how many distinct devices one customer has used in a day.

Both uses rest on a value the client made up. There is no way around that with device fingerprints: the server cannot see the browser, so it has to be told. The design question is not whether to trust the value. It is what to do with it, knowing you cannot.

What a bad value does

The fingerprint is a key into a graph. Store it as sent, and every row carrying the same string is linked. A malformed one, an empty one, the word undefined from a client bug: each becomes a node that every account sending the same junk hangs off. The shared-device query, which counts other customers on this device, then counts them. And the freeze path, which copies a frozen customer's most recent fingerprint onto the blocklist, would promote that junk string into a block that catches every other customer who happened to send it. Garbage in the graph is not noise. It is false edges between strangers.

Absent, not whatever was sent

The decision landed on 6 July 2026 and is four lines in the deposit route. The fingerprint is checked against the one shape a real one can have, sixty-four hexadecimal characters, and anything else becomes null before it reaches the guard or the consent record. The comment gives two reasons: so it cannot pollute the shared-device graph, and so it trips the missing-fingerprint signal instead. The second reason is the one most implementations forget.

Omission is not free

If an invalid fingerprint simply vanished, an attacker would have a clean move: send nothing. No shared-device match is possible for a value that does not exist. So the risk score penalises a missing fingerprint, and the comment on that branch says why in plain words: the fingerprint is client-supplied, so an attacker can omit it to dodge the shared-device check; penalise the omission so that dropping it is not a free way to look clean. The penalty is small, nowhere near a decision on its own. It exists so that absent and present-and-clean are not the same state.

What the server trusts more

In the same change, the address a request arrives from gained a shared-across-accounts signal of its own. The commit says why: it is observed by the server, so it cannot be rotated away like the client hash. That is the real hierarchy. A client hash tells you something only when it matches, and it cannot be relied on to be absent, present or unique. An address is weaker evidence per request and far harder to lie about per account. The platform leans on the thing it saw.

What it costs

A customer whose browser cannot produce a valid fingerprint gets less protection, and more of it than the anatomy row first admitted. The omission costs a few points. The shared-device signal cannot fire. And the per-day distinct-device limit is skipped for that request, because the rule only runs when a fingerprint is present. Absent is weaker evidence than a match, and it also switches one rule off. It is the right direction to fail. The alternative was a graph with forged edges in it, and a forged edge is worse than a missing one because it produces confident wrong answers about people who did nothing.

Two things the re-read found

First, the validator stops garbage, not forgery. A random value of the right shape passes, and nothing on the server can tell. That is not a flaw in the check. It is the ceiling on what any client-supplied fingerprint can ever be, and it is why the score weights the address above it. Second, the client's own fallback does not pass its own server's check. When a browser lacks the Web Crypto API, the fingerprint service falls back to an eight-character hash, which the sixty-four-character rule rejects every time. On a secure origin that path never runs, so in production it is dormant. It is two halves of one feature disagreeing, written by the same hand, and the kind of thing you only see when you read both ends in one sitting.

One older deposit route, with no callers left in the app, still passes the raw value through to the same tables. It predates the change and the change did not touch it. It should be deleted, and this decision is the argument for deleting it: a rule enforced in one route is not a rule.

Why it belongs on this page

The fingerprint crosses a hashing service in the browser, a request schema, a risk scorer, two tables with a velocity rule between them. At each layer the decision is small, and it is only correct as a whole. Validate at the edge or the graph fills with junk. Penalise the absence or the validation becomes the exploit. Weight the server-observed signal higher or the penalty is theatre. Any one of those on its own is decoration. They only hold each other up when the person writing the regex is also the person writing the score, and knows what the other hand did.

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 number that ducked every thresholdA 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.
  • 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.
  • The template never learns your nameA trainer builds a workout once and runs it for a room of people who are not the same. The data model that survived is three tables with a one-way street between them, and the model it replaced was the obvious one.