Glass House
A fingerprinting lab that shows what your browser gives away, then proves most privacy tweaks make it worse.
Outcome
18+ bits
to identify you
Stack
- Browser fingerprinting
- Privacy engineering
- Entropy math
- Canvas/WebGL/Audio
- Postgres
Links
Overview
Glass House assembles a browser fingerprint while you watch, recognises you after a hard reload with no cookie and no stored identifier, then hands you a lab where you can switch on the usual privacy defences and watch most of them make you easier to identify. Uniqueness is computed per component rather than as a joint hash, which is why the database can measure how rare you are without ever holding what you are. Every signal was measured in real browsers before any copy was written about it, and two of them turned out to be producing wrong data that looked entirely correct.
What it is
Glass House is two routes. The experience collects a browser fingerprint in front of you across seven acts, resolves it into a rarity figure, recognises you after a hard reload, and then opens a lab. This page is the engineering behind it.
The argument is that most privacy hygiene makes you easier to track rather than harder. Partial spoofing changes two signals and leaves the rest describing the machine you actually have, and that inconsistency is easier to spot than the browser it was hiding. Randomisers produce a browser that is uniquely unstable, and being reliably unstable is a durable signal in its own right. The lab exists so a visitor can watch that happen to their own number instead of being told about it.
Prior art is named on the page rather than talked around. Cover Your Tracks and amiunique measure uniqueness and stop there. webkay dumps what a browser exposes. None of them let you test a change. The signals are public knowledge. The escalation, the lab, and the storage design are the parts that are ours.
The thesis, measured
Firefox ships privacy.resistFingerprinting, the strongest hardening available without changing browsers. Running the shipped collectors against one machine with and without it produced the table the whole project rests on: seven signals change, three do not, and the result is stranger than what it replaced.
Timezone is forced to Atlantic/Reykjavik. The screen reports 1399x800 at ratio 2 instead of 1536x960 at 1.25. The core count drops from 14 to 4. The machine claims ten touch points and has no touchscreen. Every WebGL vendor string collapses to the single word Mozilla, and canvas and the WebGL render hash change on every read. The user agent, the declared preferences and the audio stack are untouched.
So the browser still says Windows, now attached to four cores, ten touch points, Reykjavik, and a display geometry nothing sells. That is not a browser blending into a crowd. It is a rarer browser than the one it replaced, and the audio fingerprint that hardening does not cover still points at the same machine either way.
Verify before you write the copy
The rule for step one was to measure every signal in real browsers before writing a word of copy around it, because a signal that silently returns a constant is worse than not shipping it at all. The entire page is an argument about accuracy. Two collectors failed that check while looking completely healthy.
The WebGL render hash was worthless. The first version drew a flat gradient triangle and returned a byte-identical hash on an Intel D3D11 driver, on SwiftShader, and in WebKit. Three different rasterisers, one hash, a component carrying no entropy at all and producing a plausible value on every run. The shipped version samples a mipmapped texture at fractional texel positions and blends overlapping rotated geometry, which is where implementations genuinely disagree. SwiftShader now separates from the Intel driver, and that separation is the control proving the component measures the rasteriser rather than the drawing.
Font enumeration was unstable on Windows Firefox. The font family list is populated asynchronously after process start, so a family can miss on one query and resolve on the next. Three consecutive reads of the same page returned 55, then 57, then 58 fonts. That would have scattered the corpus and broken reload recognition for every Firefox visitor. The collector now measures until two consecutive passes agree, with a gap wide enough to outlast the population steps, and returns nothing rather than a guess if it never settles. A cold profile returns 58, 58, 58.
Both would have shipped as working code producing wrong data. The same method found two more later, in the choreography rather than the signals: a sound toggle that armed the audio engine on pointerdown so its own click read it as already on and switched it straight back off, and a muted pad that was still audible because an audio rate connection to an AudioParam sums with the value instead of gating it. None of the four is visible from reading the code. All four were found by measuring the output.
What the public material gets wrong
The reference material treats the WebGL renderer string as close to a device identifier. It is, on Chromium. Same machine and same GPU across four engines: Chrome and Edge return the full ANGLE string down to the device id, Firefox generalises to a GPU family, hardened Firefox returns a constant, and the WebKit build reports an Apple GPU on an Intel Windows laptop. Any claim about GPU identifiability has to be scoped to Chromium, and the page scopes it. What does survive the masking is the WebGL extension count, a real property of the build: 32 on Chromium, 30 on WebKit, 14 on Firefox.
The second correction cost more. Firefox 151 and later randomise canvas readback on desktop in the default configuration, not only under resistFingerprinting, keyed per site and per browsing session. Verified directly rather than read about: same profile, browser restarted, the canvas hash changed while all ten other components stayed byte-identical across the same restart. That one measurement decided what reload recognition could be built on.
A third correction was smaller and is the same mistake in miniature. navigator.deviceMemory is widely documented as clamped to a power of two with a ceiling of 8. A Chromium shell on the same machine reported 16. It was caught while writing the inference act, where a template had hardcoded the documented ceiling and would have told a browser that reported 16 gigabytes that it had 8. Copy asserting a published figure instead of the value actually read is precisely the failure mode the verification pass exists to catch.
Recognition without a record
The page asks for a hard reload and then recognises the browser that comes back, with no cookie, no localStorage, and no server-side identifier. Recomputing a fingerprint tells you what a browser is, but it cannot tell you that you have seen it before, because that is a comparison against a record and a record has to be somewhere.
So the record sits with the visitor. The page derives a short token from the durable components, shows it, asks for the reload, derives it again, and shows the same characters. That token is a joint value over the components, which is the one thing the storage design refuses to keep, so it is computed for display, held in React state, and discarded. Nothing posts it, logs it, or puts it in a URL.
Canvas is excluded from every recognition decision because of the Firefox result above. That costs about five prior bits and buys correctness for every Firefox visitor who closes their browser, which is most of them. It is still collected, still counted, and still shown, because a canvas hash that moved while ten others held still is the clearest thing the page can say about what per-session randomisation actually does.
Matching is exact on the durable set rather than k of n. A tolerant rule buys forgiveness for a new monitor or a browser update and pays for it in the wrong currency: two near-identical corporate laptops differ only in the low-entropy incidentals, so a rule that forgives one or two mismatches is exactly a rule that calls those two laptops the same person. Exact matching turns drift into something the page can name and explain. Below four agreeing durable components it declines to claim recognition at all, because a browser hardened down to two or three readable signals is a browser about which nothing confident can be said.
Measuring rarity without storing a fingerprint
The obvious way to measure how rare a browser is would be to hash every signal together and look up how often that hash has been seen. It is wrong twice over. Statistically it is useless, because a joint lookup returns a count of one for nearly everybody and tells you nothing. And it requires the database to hold the exact object the page spends seven acts arguing nobody should be holding.
So uniqueness is computed per component. Each value contributes the negative log of its share of all visits, and the total is the sum. The database stores one row per component value: a salted HMAC truncated to 40 bits, and a count. There is no visitor table, no session table, no joint fingerprint column, no IP column, and no raw value anywhere. A test fails the build if a column capable of identifying a visitor is ever added.
Truncating to 40 bits guarantees collisions, and the collisions are the point rather than a defect waiting to be fixed by widening the hash. Without a salt the write route refuses and returns 503, because a page whose entire claim is that it stores nothing identifying cannot quietly fall back to storing unsalted hashes when an environment variable goes missing.
The same model is what makes the lab possible. A switch is either dropping a component's term or substituting its count, so blocking canvas removes one term and standardising replaces every term with the count of the corpus's most common value. Six switches ship with their arithmetic printed next to them, including the cases where the number falls for a reason that flatters the visitor: this corpus stores values, not the absence of them, so it cannot see that hardly anybody turns canvas off, and a tracker that treats a missing canvas as a value in its own right reads that browser as rarer rather than commoner.
Where it stops working
The corpus is thin and seeded from the owner's own hardware only. Nothing synthetic was added, because inventing values to pad the denominator would falsify the exact claim the page makes about rarity. A corpus of N visits can evidence at most log2(N) bits, so the rarity claim is capped at the corpus size, the live corpus size is printed next to every claim, and anything above the cap is labelled extrapolation rather than measurement. The lab leads with bits for the same reason: at this sample size the capped figure reads the same in every switch state, including full randomisation, so it cannot be the live readout.
Independence between components is assumed and is not true. The GPU strings correlate with the platform and the render hash correlates with the GPU strings, so summing self-information overstates the total. The page says so where it shows the number. The overstatement is small next to being able to explain the arithmetic, but it is real.
Signal verification ran on one Windows machine. That proves availability, stability, and whether a value is a constant. It cannot measure cross-device entropy, which only real traffic can. Safari was never tested, because Safari does not exist for Windows, and the WebKit build used as a proxy has no Web Audio at all, so the audio collector is unverified on Safari and no copy on the site claims it works there.
Two machines identical across all ten durable components are indistinguishable here by construction. No match rule fixes that, including the stored joint fingerprint this design deliberately does not have, which would collide on those two machines in exactly the same way.
And the schema gives one thing away. The write timestamp is a join key: grouping rows by write time reconstructs which component values co-occurred in a single visit, so at low traffic the joint fingerprint the design refused to store can be partly rebuilt from the timestamps alone. Coarsening the column blunts the join without closing it and costs the elapsed time the reload act reports; dropping it closes the join and removes recognition entirely. It was kept, and the final act says so, alongside the rate-limit table, which shipped holding an unsalted SHA-256 of the client IP and no expiry at all. Four billion IPv4 addresses is a space you can simply enumerate, so that column was the address with extra steps, and a row outliving its window was a standing record of when someone connected. Both are fixed, and the receipt keeps the entry in the past tense rather than deleting it, because an act that drops its unflattering line the moment the bug is gone is worth less than one that shows the repair. A receipt that shows the schema while leaving out what the schema leaks would be a worse artefact than one that owns it.