liarjs.dev
← Field notes
Fonts

Named but not measurable: the font check every cross-OS persona fails

August 19, 2026 · 9 min read

Font fingerprinting is usually taught as a set-membership question: probe a few hundred family names, see which ones the browser admits to having, and read the OS off the resulting set. Every anti-detect kernel worth the name answers that question by controlling the set. Give it an allow-list of Windows families and the page enumerates Windows families, on whatever host you happen to be running.

There is a second question that no allow-list can answer, and it takes one line of JavaScript to ask:

ctx.font = '72px "Segoe UI"';
ctx.measureText(probeText).width;

A name in a list is a string. A width is the output of a rasterizer walking a font file. If the file is not on the machine, the browser falls back, and the number you get back is not Segoe UI’s number - it is the fallback’s number, the same one every other unresolvable family gets, including a family name you invented on the spot.

Named but not measurable: every unresolvable family collapses onto one width

The collapse

Run the probe against a macOS host presenting a Windows persona and the numbers come out like this. Same string, same 72px, families the persona claims are installed:

family width
Segoe UI 1287.88
Calibri 1287.88
Cambria 1287.88
Consolas 1287.88
LiarJSNoSuchFamily7Q 1287.88

The last row is the control: a family name chosen so that no host on earth has it. Everything above it ties the control exactly. On a real Windows machine those four numbers are four different numbers, none of them equal to the control, because behind each name there is a different file.

That is the whole check. It does not need a reference table of what Segoe UI is supposed to measure - it needs one control family and the observation that a claimed OS’s own font families must not measure the same as a family nobody has installed.

The measurement above comes from a mac with an M5 Pro running a stock Chromium build. The control width there for our own probe string is 1804.54, and all eight Windows signature families, all seven Android ones and all seven Linux ones return exactly 1804.54. The eight macOS families return eight different numbers. The host answers honestly no matter what the User-Agent says, because font files are not something a browser flag can conjure.

Why enumeration misses it

You would expect the classic font check to catch this anyway: if the Windows families are not really there, surely they should not enumerate either?

They do, and that is the point. Enumeration by measureText works by comparing 72px "X", sans-serif against 72px sans-serif and calling X present when the widths differ. On a masked kernel the allow-list is applied at the font-matching layer: the Windows names are in the enumerable set, and the mac’s own families are subtracted out of it. So the enumeration path reports a small, plausible, Windows-shaped set, while the macOS families it should have found are gone. The OS-by-fonts heuristic, which scores the detected set against per-OS reference lists, sees no macOS evidence at all and lands on “unknown” - and a check that lands on unknown quietly skips itself rather than firing.

The result is a masked browser scoring better than an unmasked one, because it removed the evidence that would have contradicted it. Measuring widths instead of names closes that hole: subtracting the mac families does nothing to the fact that the Windows names lead nowhere.

The generic families are worse

Three family keywords must resolve on every device that renders text at all:

ctx.font = '72px sans-serif';  // ...then serif, then monospace

A real machine gives three different widths, because sans-serif, serif and monospace map to three different fonts. A persona that points its generic mappings at families the host does not have gives one width, three times.

Android personas run on desktop hosts are the pathological case. The AOSP generic map points at Roboto, Noto Serif and Droid Sans Mono; none of the three exists on a stock Mac or Windows box; all three fall through to the same fallback. Measured on a mac host running an Android persona, sans-serif, serif and monospace all returned the same width, and Roboto, Noto Sans, Noto Serif, Noto Sans Mono, Droid Sans Mono and Dancing Script returned that same width again. Six named families and three generic keywords, one number.

There is no device in the world where serif and monospace measure the same. This is a cheaper and more reliable signal than any enumeration, and it costs three measureText calls.

What the commercial detectors do with it

This is not a theoretical gate. Recording every Web API a page touches, on a site running a commercial anti-bot SDK, measureText came back at 1011 calls in a single session - alongside 4853 reads of PointerEvent.pointerType and 1279 of MouseEvent.clientX/Y. Font metrics are not a side dish on that surface; they are one of the two or three heaviest things the script does.

A controlled bisect against that endpoint, changing one variable per arm and holding the host, the browser build and the network path constant, went like this:

arm verdict
Windows persona on a mac host rejected
plus six host leaks fixed in the browser build (window size, isExtended, GL extension table, WebGPU features, ICE shape, heap limit) rejected
font masking switched off entirely, host fonts exposed rejected
plus the full captured WebGL surface from a real Windows device rejected
plus metric-compatible font stand-ins passed
control: macOS persona, no cross-OS claim at all passed
control: stock Chrome passed

Five things were fixed before the font change and none of them moved the verdict. The font metrics did.

Fixing it, and the honest version of the fix

There are two ways out, and they are not equally good.

Ship metric-compatible substitutes. Selawik is metric-compatible with Segoe UI, Carlito with Calibri, Caladea with Cambria - all redistributable, all designed for exactly this substitution problem in document layout. Register them with the platform’s font system and match them behind the original family name, keeping the substitute itself out of the enumerable set so that nobody can find a Selawik on a machine claiming to be Windows. After that, Segoe UI, Calibri and Cambria measure 1369.05, 1278.42 and 1269.14: three real numbers, none of them the control. The gap is what has no substitute. Consolas has no metric-compatible open replacement, so it still collapses. The defensible position for a family in that state is to drop it from the allow-list entirely - “this machine does not have Consolas” is a normal thing to be, “has Consolas but cannot measure it” is not.

Or do not cross the OS boundary. A macOS host presenting a macOS persona has every Apple font file it claims, and the check is free. The cost is that identity has to come from somewhere else: canvas, audio and DOMRect seeds, timezone, WebRTC exit, plus the machine-to-machine variation that exists inside one OS family - GPU model string, screen size, core count. Which is a smaller lever than pretending to be a different operating system, and a real one.

Android is the happy case in both directions: Roboto, the Noto families, Droid Sans Mono and Dancing Script are all Apache-2.0 or OFL. You can ship the actual fonts, the metrics are exact, and no aliasing is needed at all.

The five other things the same bisect turned up

The font gate was decisive, but the arms above were not wasted; each fixed a real host leak that a different detector will read:

  • The window was larger than the screen. outerWidth 1512 on a persona reporting availWidth 1280. A window cannot exceed its display, so the bigger of the two numbers is the real machine. Clamp the window into the persona’s screen, or do not override the screen.
  • screen.isExtended was true on a persona with a single hard-coded resolution. That flag comes from the window system, not from the identity.
  • The WebGL extension table was the host’s. The renderer string said NVIDIA/D3D11; the extension list still carried WEBGL_compressed_texture_pvrtc, _astc, _etc and _etc1. Those are exactly the four this Apple GPU exposes and exactly the four a desktop NVIDIA part through ANGLE does not. Rewriting the string without the capability table is the same mistake as the font list without the files.
  • WebGPU repeated the confession. texture-compression-astc and texture-compression-etc2 in features under a desktop vendor claim.
  • performance.memory.jsHeapSizeLimit was 4395630592. That is what a desktop Chrome stable reports on macOS; on Windows the same build line reports 4294705152. It is not a hard constant - headless and low-memory runs land elsewhere - but landing exactly on one platform’s figure while claiming another is a straight contradiction.
  • ICE gathering returned zero candidates. Real Chrome always produces at least one host candidate, an mDNS .local name, even with no route out. Zero is not privacy; it is a state so rare it is a signal of its own - and worse, a naive WebRTC check reads “no public IP leaked” and scores it as a pass.

In the scanner

All of the above is now in liarjs, as nine new checks. The font ones are font-metrics (the claimed OS’s signature families have no metrics behind them, −20) and font-generic (the three generic keywords collapse onto one width, −16); the rest arrived as window-screen, screen-extended, gl-ext-formfactor, webgpu-features, heap-os, webrtc-candidates, and browser-age for a build pinned several majors behind stable. The collector gained a fourth font path alongside the three enumeration paths: raw widths for the generic keywords and for each OS’s signature families, measured against a control family, with per-OS scores computed from what actually resolves.

The pattern underneath all nine is the one this site keeps arriving at. A fingerprint surface has a layer you can rewrite - strings, lists, flags - and a layer that is produced by machinery: rasterizers, drivers, allocators, the window system. Rewriting the first without moving the second does not make you look like the target. It makes you look like something that has never existed, which is a much shorter list to be on.

Sources & further reading
  1. MDN, TextMetrics
  2. CSS Fonts Module Level 4, font matching algorithm
  3. Selawik, a metric-compatible Segoe UI substitute (Microsoft, OFL)
  4. Carlito and Caladea, metric-compatible with Calibri and Cambria
More field notes
← Back to field notes