Skip to content

Fingerprint

Some flows must not be repeatable by the same person under a different address — an early-access cap, a one-per-device trial, a signup that hands out something scarce. fingerprint() gives those flows a stable, opaque identifier for the machine and the browser profile, with no login and without naming anyone. It is a cost-raiser, not a wall: it closes the cheapest evasion — a second browser profile on the same machine — and leaves a second machine, a VM, or a scripted forge to the gate’s other checks.

The value is <measured>.<token>. The measured half describes the machine; the token half is minted once and kept in three browser stores. They fail in opposite directions — the measured half survives a browser switch, the token survives a hardware change — so carrying both is what makes the identifier cross-browser and collision-proof.

BROWSER PROFILE SERVER (workerd)
─────────────── ────────────────
WebGL limits ─────► measured ─┐ per MACHINE
├─► <measured>.<token> ─► gate (EmailCode.request)
nk:auth ─────┐ │ │ deviceId(measured) ← HKDF, server-only key
HTTP cache ──┼─► token ────────┘ per BROWSER │ device = FP_COOKIE ?? claim
(favicon) │ │ exact / class match
FP_COOKIE ◄──┴──────────── Set-Cookie (authoritative) ──────┘ → early_access + block_log
axisscopewhat it is
measured (WebGL)per machinea SHA-256 of ~27 WebGL 1/2 hardware limits + platform, devicePixelRatio, timezone. Chosen because a browser cannot lie about a limit a real GPU program allocates against — so it reads the same in Chrome, Safari, and Brave on one machine. Canvas and screen.width are excluded precisely because privacy browsers randomize them. This is the class tier.
HttpOnly cookie (FP_COOKIE)per browserthe server’s own authoritative copy, written in the gate. No page script can read or forge it, so a forged claim can’t displace a browser you already know — and it recognizes a scripts-off visitor who sends no claim at all.
app store (nk:auth)per browserthe app adopts the resolved token into its own store(). Survives a cookie clear or a cache eviction.
HTTP cache (favicon route)per browserthe token route’s response, cached by the browser. Survives a localStorage clear.

The three token stores hold the same token and restore one another, so the identity survives the partial clears people actually perform. A caller-supplied token is trusted only when it is a valid {1,16} token — a stale or corrupt one (a salt left over from an earlier scheme, say) is discarded and the chain drops through to the next store, so one poisoned axis can never corrupt the value.

The token store that needs no JavaScript to persist is the browser’s own HTTP cache. The adapter serves the token as a favicon — a real 16×16 image/x-icon with the token appended to the icon bytes — immutable for a year, so pasting the URL shows a mundane icon, never the token:

fingerprint() needs a token
│
▼
valid caller token? ──yes──► use it
│ no
▼
GET /icon.ico ┌──────── Worker (first visit only) ────────┐
├─────────────────────────► 16×16 icon bytes ‖ 8-char token │
│ │ Content-Type: image/x-icon │
│ │ Cache-Control: private, immutable, 1y │
│ │ CDN-Cache-Control: no-store ← no shared │
│ └───────────────┬──── edge ever holds it ───┘
│ │
browser HTTP cache ◄───────────────────────────┘
│ return visit: replayed from the browser's OWN cache — the Worker is
│ never hit, so the ORIGINAL token comes back. It behaves as storage
▼ precisely by ceasing to be a request.
token = the last 8 bytes of the icon

The two-tier cache split is the trick: Cache-Control: private, immutable lets the browser cache it per-profile, while CDN-Cache-Control: no-store stops any shared edge from holding one person’s token. The path (/icon.ico here) and lifetime are set with cloudflare({ fingerprint: { path, maxAge } }).

The decision — two tiers, asymmetric proof

Section titled “The decision — two tiers, asymmetric proof”

A null fingerprint (SSR, or no signal on any axis) skips the check entirely — the gate fails open, because refusing a real person to stop one duplicate is the worse error.

  • Exact match — the whole <measured>.<token> equals a verified row → same browser profile. A minted token can’t collide by chance, so a hit is proof: it blocks alone, no network needed, and it catches the VPN/hotspot case the measured half structurally can’t.
  • Class match — the measured prefix matches but the token differs → same machine, different browser (or cleared storage that minted a fresh token). That’s evidence, not proof, so it blocks only when the network also matches (request.cf.asn against the row’s signup_asn) — a false block then needs two coincidences. A class match on a different network is let through.

Native clients measure differently — and can measure more

Section titled “Native clients measure differently — and can measure more”

Everything above describes a browser. A CLI, a desktop app or any native client has no WebGL context and no cookie jar, so none of the four axes is available to it. phaze::auth::fingerprint::machine() is the native counterpart, and it is not a port — it is built from what a native process can reach, which turns out to be strictly more.

NATIVE CLIENT SERVER
───────────── ──────
IOPlatformUUID ────┐ per UNIT
├─► SHA-256 ─► <machine> ─┐
GPU render ────────┘ what the hardware DOES ├─► <machine>.<token> ─► deviceId(machine)
│ (HKDF, server-only key)
assigned token ──────────────────────────────┘
the app's own store, outside its config dir

The GPU half is the axis a browser has to refuse. This page’s own rule above — pixel output is deliberately absent, because rasterisation differs across engines — is a statement about ANGLE versus direct GL, not about hardware. One machine hashes two ways in two browsers, so the browser is forced back onto declared limits, and declared limits only identify a GPU class. A native process has one rendering path, so it can hash what the GPU actually computes: a shader runs transcendentals in a loop and the float bytes come back. Precision and approximation choices differ per GPU and driver, and none of it is something a value can simply state.

The platform UUID removes the collision the class tier lives with. Where the browser’s measured half puts two same-model machines in one bucket and must therefore be treated as evidence, this one is per-unit. It is still a claim — a patched binary sends anything — but a prefix match is proof of a machine rather than of a machine class.

The two halves still fail in opposite directions, which is why both are carried. The GPU signature is stable per (hardware, driver) and a driver update moves it; the stored token does not care. The token dies with its store; the hardware read does not. That is the same reasoning as <measured>.<token> in a browser, reached from different constraints.

machine() also reports WHICH axes answered, because the hash cannot. A UUID-only reading, a GPU-only reading and both produce an identical 43-character digest, so an axis could go dark across a whole fleet and the data would look perfectly healthy. The caller records the label the same way a signup records fingerprint_type, and for the same reason — a gate that only writes when it acts cannot be audited:

valuewhat it means
bothnormal
uuidthe GPU declined — a VM, a headless host, a driver fault
gpuIOKit declined — unusual, worth seeing
noneno signal at all — an old client, an unsupported host, or both axes down

There is deliberately no mismatch. That value catches a claim contradicting the server’s own HttpOnly copy, and a native client has no such copy to be outranked by — so this carries the signal-stripping half of what fingerprint_type does for a browser, and never the forgery half.

Deliberately not hashed: machine model, CPU brand, core count, installed memory. Each is identical across a whole product line, so none adds variance — and two of them move on a repair or an upgrade, which would let a service visit mint a new identity.

The data — what the gate reads and writes

Section titled “The data — what the gate reads and writes”

Two tables. The signup row the gate reads, and an append-only log of what it refused.

early_access — the signup row the gate reads + writes block_log — one row per REFUSAL
──────────── ─────────
email TEXT email the refused address
fingerprint TEXT <deviceId(measured)>.<token> primary_email ─► the row it collided with
fingerprint_type TEXT cookie│webgl│token│mismatch│none fingerprint_type
signup_asn INTEGER the network at signup fingerprint the refused device value
signup_country/_city TEXT blocked_asn
verified INTEGER only verified=1 rows gate blocked_country / blocked_city
blocked_attempts INTEGER ─── +1 on each refusal ─────────► blocked_at TEXT datetime('now')
(the fast false-positive counter) (the who/where behind each increment)
  • fingerprint stores the derived device — the measured half is run through HKDF under a server-only key (deviceId), so a stolen database row can’t be replayed as a browser. The token rides in the clear (it’s random and says nothing), which is what lets one column answer both questions: an exact match is the same browser, a prefix match is the same machine class.
  • fingerprint_type records which axis the value came from — the technology, named, because a word means one thing. It exists to be read: a climbing rate of none is people stripping the signal; a cluster of mismatch on one row is the forged-claim shape.
  • blocked_attempts is a cheap counter on the matched row — the false-positive detector. One or two is a person trying a second address; a number that keeps climbing means the fingerprint is matching strangers, and every one was turned away silently.
  • block_log is the detail the counter can’t hold: which address, colliding with whom, on what signal, from where. match_type isn’t stored — it’s derivable by comparing a row’s fingerprint to the primary’s (whole value equal = same browser; prefix only = same machine).