Skip to content
Fingerprinting

The font stack under the user agent: two OS tells a persona cannot reach

Text is drawn by the operating system's own font scaler and font manager. Two measurements read them directly, and neither cares what the user agent says.

Pim
Pim· Clearcote Research
8 min read

Key takeaways

  • Step a font size up by 0.01 px and count how often a line of text changes width: Chrome on Windows changed on 99% of steps, a Linux browser on 66%, and the same Linux browser presenting Windows on 66%.
  • Ask whether a font is installed two ways, by measuring it and by loading it with local(): on Windows the answers always agree, while a Linux build presenting Windows measured Segoe UI and Georgia as installed but could not load either one.
  • Both surfaces sit below anything a user-agent or platform setting reaches. The fix is not a better spoof: run on the operating system you claim, or claim the one you run on.

Text is drawn by the host, not the browser

A browser can say anything about its operating system. navigator.platform, the user agent and the Client Hints are strings, and a patched engine can make them agree with each other. Text rendering is different. Chromium lays text out with HarfBuzz and rasterises it with Skia, but the glyph outlines and their sizes come from the platform: DirectWrite on Windows, Core Text on macOS, FreeType on Linux, Android and ChromeOS. The fonts come from the platform's font manager too.

So two parts of every page load are answered by the host operating system itself. This article measures both on real browsers and turns each into a check in the fingerprint test. The starting point was published research from Scrapfly on text metrics under FreeType and on font enumeration. We reproduced the effects, found one place where the published model is not quite right, and found one trap that would make a naive check accuse real Linux users.

All measurements were taken on 2026-09-24 with three browsers: Chrome on Windows 11, and Clearcote 149 in its Linux Docker image, once with its default Linux persona and once with CC_PLATFORM=windows. The third one is a Linux browser telling every page that it runs on Windows, which is exactly the case these checks exist for.

The scaler: how often a hundredth of a pixel matters

Measure a line of text at font sizes a thousandth of a pixel apart and the width only ever changes on a hundredth, on every system we tried. Blink snaps a font size to hundredths of a pixel before the scaler ever sees it. What the scaler does with those hundredths is where the operating systems part ways.

We stepped a 54-character string from 20 px to 27.2 px in 0.01 px steps, 720 steps in all, and counted the steps that changed its width:

  • Chrome on Windows 11: 714 of 720 steps changed (99%).
  • Clearcote on Linux, Linux persona: 476 of 720 (66%).
  • Clearcote on Linux, Windows persona: 476 of 720 (66%).

The figures were identical for sans-serif, serif and monospace, and identical whether the width came from canvas measureText or from getBoundingClientRect on a span. That is what a property of the scaler looks like, as opposed to a property of one font. It also means the check needs no reference widths at all: the browser's own widths are only compared with each other, so the installed fonts and the browser version do not matter.

DirectWrite keeps the hundredths, so nearly every step moves the text. FreeType works in 1/64-pixel fixed point, so neighbouring hundredths often land on the same internal size and about a third of the steps change nothing. The Windows persona does not help: the user agent says Windows and the text still moves on two steps in three.

Where the simple model is not quite right

The published explanation is that FreeType floors every size to a 1/64 grid. That model predicts at most 64 changes per 100 hundredths, since a 1/64 grid only has 64 lines per pixel. We measured 66. Looking at exactly which steps changed, most of them do fall where a 1/64 floor says they should, but not all: a handful of steps change inside what the model calls a single cell.

The difference is small, and it does not weaken the signal: 66% and 99% are far apart. But it decides how the check should be written. A check that tests the exact 1/64 model would miscount on real Linux machines. The audit row therefore scores the measured rate, not the model: 90% or more reads as a scaler that keeps hundredths, 40% to 80% reads as FreeType, and anything in between says nothing.

The HiDPI trap

Blink snaps the size in CSS pixels, but FreeType then works on the device size. At a higher device pixel ratio its grid is finer in CSS terms, so it changes the width more often. We ran the Linux build with Chrome's own --force-device-scale-factor:

  • 1x: 63% of steps changed.
  • 1.25x: 68%.
  • 1.5x: 82%.
  • 2x: 99%, indistinguishable from Windows.

Windows stayed at 96% at every ratio we emulated. So the two patterns are not symmetric. The FreeType pattern means FreeType at any ratio. The Windows pattern only means something at a ratio of exactly 1, because a real Linux laptop at 2x produces it too. A check that ignored this would tell every Linux user on a HiDPI screen that they were secretly on Windows. The audit row declines in that case instead.

One side effect is worth noting. The Linux build forced to 2x still reported a devicePixelRatio of 1, because its persona pins that value. A browser that reports 1 while rendering at 2 produces the Windows pattern under a ratio of 1, which the row then reads at face value. That is not a false accusation: the reported ratio and the rendering disagree, and the row is reading the rendering.

The font manager: measured is not the same as installed

The classic way to find installed fonts is to measure them. Draw a string in "Segoe UI", monospace, compare its width with monospace alone, and a difference means Segoe UI answered. The newer way asks by name: new FontFace(x, 'local("Segoe UI")').load() resolves only if the font manager holds a face with that name.

We asked both questions for ten core Windows faces: Arial, Segoe UI, Consolas, Calibri, Cambria, Times New Roman, Courier New, Tahoma, Verdana and Georgia.

  • Chrome on Windows 11: all ten measured as installed and all ten loaded by name.
  • Clearcote on Linux, Linux persona: Arial, Times New Roman and Courier New measured as installed but did not load.
  • Clearcote on Linux, Windows persona: eight faces measured and loaded; Segoe UI and Georgia measured as installed but did not load.

On Windows both questions go to the same DirectWrite font collection, so they cannot disagree. On Linux they disagree all the time, and correctly: fontconfig answers a request for Arial with a metric-compatible clone such as Liberation Sans, which measures exactly like Arial but is not named Arial. That is why the middle row is normal and why the audit asserts this only under a Windows claim. Under a Windows claim, a face that measures but will not load was never installed. Some other font is answering for it with the right metrics under the wrong name.

The existing font rows in the audit did not catch this persona. The CSS2 system-font keywords resolved to generics and the row declined; the typeface-distinctness row passed, because the stand-ins have distinct widths just as the real faces do. The load-by-name question is the one that separates them.

What this means for Clearcote

Both checks fail Clearcote's own Linux build when it presents Windows. We are publishing that rather than hiding it, because it is the honest answer to a question every user of a Linux container should ask. It is also why the Linux build claims Linux by default: with its native persona, the scaler row passes and the font row correctly stays silent.

If you need a Windows identity, run it on Windows. The Windows build draws text with DirectWrite and loads fonts from a real DirectWrite collection, and hosted browsers run on physical Windows machines. The deployment guide now says this next to the Docker persona settings.

The through-line

Both tells share a shape with the other host-level rows in the audit, such as the spec invariants and the engine checks in identifying the engine behind the user agent. The browser is asked something only the machine underneath can answer, and the answer is compared with what the browser claims. No population data is needed and no reference table can go stale.

Both also show why a check has to be measured before it is trusted. The published scaler model was close but not exact, and a straightforward implementation would have convicted HiDPI Linux users. The two new rows, text-scaler-vs-claimed-os and windows-fonts-load-by-name, run in the fingerprint test with their measurements and limits written out in the check reference.

#fingerprinting#fonts#coherence#bot-detection

Clearcote puts this into practice

An open-source Chromium with fingerprint control compiled into the engine. A drop-in for Playwright & Puppeteer.

Free for one browser with GitHub. No card.