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.
Four axes, one value
Section titled “Four axes, one value”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| axis | scope | what it is |
|---|---|---|
| measured (WebGL) | per machine | a 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 browser | the 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 browser | the app adopts the resolved token into its own store(). Survives a cookie clear or a cache eviction. |
| HTTP cache (favicon route) | per browser | the 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 cache mechanic
Section titled “The cache mechanic”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 iconThe 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.asnagainst the row’ssignup_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 dirThe 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:
| value | what it means |
|---|---|
both | normal |
uuid | the GPU declined — a VM, a headless host, a driver fault |
gpu | IOKit declined — unusual, worth seeing |
none | no 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)fingerprintstores 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_typerecords which axis the value came from — the technology, named, because a word means one thing. It exists to be read: a climbing rate ofnoneis people stripping the signal; a cluster ofmismatchon one row is the forged-claim shape.blocked_attemptsis 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_logis the detail the counter can’t hold: which address, colliding with whom, on what signal, from where.match_typeisn’t stored — it’s derivable by comparing a row’sfingerprintto the primary’s (whole value equal = same browser; prefix only = same machine).