Text Rendering Is an OS Fingerprint: From the Scaler to the Font List
Every operating system draws text with its own engine: DirectWrite on Windows, Core Text on Apple, FreeType on Linux and Android. Chromium wraps all three behind HarfBuzz and Skia, and the seams show. The set of installed fonts names the OS, the generic and metric-alias fallback chains name it again, and the sub-pixel advance names the scaler underneath. A browser that claims Windows while running on a Linux server leaks all three at once. This walks the stack from how each OS rasterizes a glyph, through how Chromium shapes and measures text internally, to the three font signals a fingerprinter reads, with live numbers from genuine Windows, macOS, and Ubuntu machines.
A page asks the browser to draw a string. Between that request and the pixels, the text passes through a shaping engine, a scaler, and a platform font library, and each layer leaves a mark a script can read back. The font set that answers a probe names the operating system. The face a generic like monospace falls back to names it again. The sub-pixel width of the drawn glyphs names the scaler under all of it. To understand why a spoofed browser leaks its real OS through text, you have to start with how the OS draws text in the first place.
How each OS draws a glyph
A font file stores each glyph as outlines in abstract design units, plus an advance, the horizontal step to the next glyph, also in design units. Times New Roman uses 2048 units per em; its n advances 1024 of them. To put that on screen at a pixel size, something has to scale the outline, decide where the edges land on the pixel grid, and hand layout a concrete advance. That something is the platform font engine, and every OS ships a different one.
Windows: DirectWrite.1 DirectWrite is Microsoft’s text and glyph API, reached through IDWriteFontFace. It reads a glyph’s design metrics, applies the font’s TrueType hinting2 (bytecode in the font that nudges outline points onto the pixel grid at small sizes), and returns an advance quantized to its own grid. It is bound to the Win32 subsystem; there is no portable copy that produces the same numbers off-Windows.
Apple: Core Text.3 Core Text is the text engine on macOS and iOS, reached through CTFont. Same job, different implementation and different rounding, though as we will see its advance output matches DirectWrite’s bit for bit for the same font and size. macOS Chrome draws through Core Text; iOS Safari and Chrome also draw through Core Text, but under WebKit rather than Blink, which changes the numbers again.
Linux and Android: FreeType.4 FreeType is the open-source scaler Chrome links on Linux and Android. It rasterizes outlines and computes advances at a pixels-per-em value snapped to a 1/64 grid. Its hinting and rounding decisions differ from both DirectWrite and Core Text, so the advance it hands back for a fractional size is its own.
Three engines, three sets of rounding rules. At an integer pixel size they all compute design_units * size / unitsPerEm and agree to the bit. At a fractional size they diverge, and the divergence is stable and readable, which is the subject of a companion post on sub-pixel advances. Here the point is only that the engine is per OS.
How Chromium draws text internally
Chromium does not call DirectWrite, Core Text, or FreeType directly from layout. It shapes text with HarfBuzz5 and rasterizes through Skia6, and only Skia talks to the platform engine. The path for a run of text is:
- Blink layout hands a styled text run to the HarfBuzz shaper (
HarfBuzzShaper). - HarfBuzz turns characters into positioned glyphs. It needs each glyph’s advance to place the next one, and it gets advances through a callback table (
hb_font_funcs_t). In Chromium that callback isHarfBuzzGetGlyphHorizontalAdvanceinharfbuzz_face.cc. - The callback calls Skia:
SkFontGetGlyphWidthForHarfBuzzreads the advance from a Skia strike (SkStrikeRef::getWidths). - Skia calls the platform scaler.
SkScalerContextis the abstraction; the concrete class isSkScalerContext_FreeTypeon Linux,SkScalerContext_win_dwon Windows (whose advance issize * glyphDesignAdvance / designUnitsPerEmon the DirectWrite grid), and the Core Text scaler on macOS.
styled text run"] HS["HarfBuzzShaper, hb_shape
characters to positioned glyphs"] CB["HarfBuzzGetGlyphHorizontalAdvance
harfbuzz_face.cc, the shaping callback"] SK["SkFontGetGlyphWidthForHarfBuzz
skia_text_metrics.cc, Skia strike"] SC["SkScalerContext
Skia per-platform scaler"] BL --> HS --> CB --> SK --> SC SC --> FT["FreeType scaler
Linux, Android"] SC --> DW["DirectWrite scaler
Windows"] SC --> CT["Core Text scaler
macOS"]
Only Skia touches the platform engine, so the three OS branches at the bottom are the only place the advance can differ. That is why the same font at the same fractional size measures one way under FreeType and another under DirectWrite or Core Text.
There is a wrinkle worth naming, because it shows how tightly Chromium tracks each platform. On Apple, Chromium keeps two advance callbacks and picks between them per font: a font with a trak table (Core Text tracking) but no sbix table (bitmap glyphs) is measured by HarfBuzz’s own advances, and everything else by Skia’s. On non-Apple builds there is one callback. The selection lives in HarfBuzzSkiaFontFuncs::GetFunctions.
The consequence for a spoofing browser is structural. Scrapium is a custom Chromium that runs on Linux servers. Its SkScalerContext is always SkScalerContext_FreeType, because that is the only scaler linked into the binary. Change the User-Agent, navigator.platform, and client hints to say Windows, and the shaping path above still ends at FreeType. Every advance the page can measure carries FreeType’s rounding, not DirectWrite’s. Fixing that means intercepting at the callback (harfbuzz_face.cc) and substituting the advance the impersonated OS’s engine would have produced, which is what the fork’s deps/font library does.
That is the metric side. The other font signals sit above the scaler, in which fonts exist and what the fallbacks resolve to.
Signal one: the installed font set
The set of fonts a browser can render is one of the oldest fingerprint inputs and one of the most OS-specific. Segoe UI, Calibri, and Marlett ship with Windows. Helvetica Neue, PingFang SC, and Apple SD Gothic Neo ship with macOS. DejaVu Sans, Liberation Serif, and Ubuntu ship with a Linux desktop. Nobody installs these by choice; they arrive with the OS. Read the list back and you have read the OS.
No browser API hands over the list. navigator.fonts was removed, and queryLocalFonts7 needs a permission prompt no scraper wants to trigger. So fingerprinters infer it by side channel. The classic method uses measureText: set a family with a generic behind it, measure a string, and if the width differs from the generic alone the family exists and drew the text.
function present(family) {
const c = document.createElement('canvas').getContext('2d');
const probe = 'mmmmmmmmmmlli';
const generics = ['monospace', 'sans-serif', 'serif'];
let differs = 0;
for (const g of generics) {
c.font = '72px ' + g;
const base = c.measureText(probe).width;
c.font = '72px "' + family + '",' + g;
if (Math.abs(c.measureText(probe).width - base) > 0.01) differs++;
}
return differs >= 2; // require 2 of 3 to reject single-generic aliasing
}
Run the same marker fonts on three genuine machines, all Chrome 150, and the split is clean:
present(family) | Windows | macOS | Ubuntu |
|---|---|---|---|
| Segoe UI | true | false | false |
| Calibri | true | false | false |
| Marlett | true | false | false |
| Helvetica Neue | false | true | false |
| PingFang SC | false | true | false |
| DejaVu Sans | false | false | true |
| Ubuntu | false | false | true |
Each row belongs to one OS and is absent on the other two. A profile that claims Windows and returns false for Segoe UI contradicts itself on the first probe.
The stronger method uses the FontFace API.8 Construct a face pointing at a local family and try to load it:
async function present(family) {
try { await new FontFace(family, `local("${family}")`).load(); return true; }
catch { return false; }
}
local() resolves against installed fonts only, so a successful load means the family is present. This is more precise than the width probe. It catches faces whose Latin glyphs match the fallback width but whose script glyphs do not, and it catches weight and style variants a width probe collapses. On the same genuine Mac, over the same fixed candidate list a production collector uses, the width probe reports 365 families present and FontFace.load reports 642. The width probe under-samples because most fonts draw plain Latin identically to the system default; the load probe asks the real question. Production collectors run the load probe, so that is the surface a profile has to match.
The list is per machine, not per OS in the abstract. Real machines carry one install, not the union of every font a platform ever shipped. We rebuilt three references, one clean machine per OS, and set each persona’s advertised list equal to what that machine exposes:
| Reference machine | Detectable families |
|---|---|
| Windows 11 25H2 + Office | 178 |
| Ubuntu 24.04, default font packages only | 255 |
| macOS 26.4.1, stock | 646 |
The counts are specific and not interchangeable. A clean Windows 11 box with Office exposes 178 detectable families. A clean Ubuntu desktop with only its default font packages (fonts-dejavu, fonts-liberation, fonts-noto, fonts-ubuntu, fonts-freefont, fonts-urw-base35) exposes 255, dominated by the Noto script set. Stock macOS exposes 646, because Apple ships a very large system set plus a long tail of on-demand faces. A profile that claims one OS and returns a count from a different machine disagrees with itself.
Signal two: fallback and aliasing
The font set is what exists. Fallback is what happens when a page names something that does not exist, or names a generic. Both are resolved by the OS font configuration, and both differ per OS in ways a page can measure.
Ask each machine what its CSS generics actually render as, by measuring the generic and matching the width against named faces:
| generic | Windows renders as | macOS renders as | Ubuntu renders as |
|---|---|---|---|
sans-serif | Arial | Helvetica / Arial | Liberation Sans |
serif | Times New Roman | Times New Roman | Liberation Serif |
monospace | Consolas | Menlo | DejaVu Sans Mono |
system-ui | Segoe UI | San Francisco | Noto Sans |
monospace and system-ui are clean discriminators. Windows monospace is Consolas, macOS is Menlo, Ubuntu is DejaVu Sans Mono, three different faces at measurably different widths (monospace measured 791.72 px on Windows against 866.95 px on both macOS and Ubuntu for the same string, and those two draw different faces at the same total width, separable by other strings). system-ui is a three-way split: Segoe UI, San Francisco, Noto Sans.
Metric aliasing is a second, sharper layer. Linux distributions ship a config (30-metric-aliases.conf) that redirects the MS web-font names to metric-compatible open replacements: Arial to Liberation Sans, Times New Roman to Liberation Serif, Courier New to Liberation Mono. So on Ubuntu, measureText of Arial returns a width, the Liberation Sans width, even though no Arial file exists. On Windows and macOS Arial returns the real Arial. Windows has its own legacy aliases: the old bitmap names MS Sans Serif and MS Serif resolve to Microsoft Sans Serif and a serif face. Each of these is a name that renders on one OS and its aliasing distro, at that OS’s width, and a fingerprinter that measures the alias reads which resolution chain is behind it.
For a spoof this is a trap on top of the font set. It is not enough to ship the right files. The fork points fontconfig at its own per-OS pool and pins the generics and aliases to the faces the reference machine actually renders, measured through the browser rather than read from fc-match, because Chrome’s Skia layer does its own font sort and can override what fc-match reports. On the clean Ubuntu reference, fc-match sans-serif names Noto Sans while Chrome renders sans-serif as Liberation Sans; the pinned config has to match what Chrome renders, or the default sans width is wrong on every unstyled paragraph.
Signal three: the advance has to be right
Matching the set means a family renders. Matching the fallback means the generics resolve correctly. Neither means a family renders at the right width. That is where the font set meets the scaler.
Most fonts scale cleanly: advance is design_units * size / unitsPerEm, and once the file is present a faithful pool and a real machine agree. Franklin Gothic Medium does not. It is an old, heavily hinted font whose TrueType instructions rewrite glyph advances at render time. On genuine Windows the letter m advances 1688 design units; the raw hmtx table in the file says 1782. The hinting moves it, and it moves 465 of the font’s 656 glyphs, each by its own amount. Neither the file’s stored metrics nor a from-scratch hint interpreter reproduces the genuine number, so we captured the per-glyph hinted advance from the reference box directly. Measured on that genuine Windows box, at 2048 design units per em:
| Franklin Gothic Medium glyph | m | A | x | W | i | e |
|---|---|---|---|---|---|---|
| genuine Windows advance | 1688 | 1245 | 1011 | 1788 | 543 | 1095 |
file hmtx advance (m) | 1782 |
The value is stable across sizes, so one small table for the one font in the Windows set that needs it makes the spoofed advance read back bit-identical to the reference machine. Every other family matches on the standard scale.
A hand-installed font is a tell
The reference machine has to be clean, because whatever fonts the developer added by hand leak into the pool and the advertised list, and now the profile carries fonts no stock install has.
We hit this on the macOS reference. The box had IBM Plex, Fira Mono, and Space Mono installed by its owner, plus a couple of vendor fonts under /Library/Fonts. IBM Plex alone accounted for over a hundred files in the shipping pool and five detectable family names. None ship with macOS. A fingerprinter that probes IBM Plex Mono, sees it present, and knows stock macOS does not carry it has a contradiction, the same one as a missing font running the other way. We dropped the five user families and stripped 126 user font files from the pool, and cross-checked the remainder against a clean macOS install.
The same discipline shaped the others. The Linux reference is a fresh Ubuntu container with only default font packages, not a developer laptop with design fonts and language packs added over years. The Windows reference is a clean install plus Office, a common real configuration, rather than a merged everything-pool that matches no real machine. One clean machine is a coherent thing a real user has. A union of every font that ever shipped is a machine that does not exist, and a probe that knows the platform’s real default set can tell.
What lines up when it is right
Three lists have to be the same list: the families the reference machine exposes, the families the profile advertises, and the families the pool can render. The generics and aliases have to resolve to the same faces the reference machine renders. And every advertised family has to draw at the reference machine’s advance, down to the hinted glyphs. Get the set right and the fallback wrong and an unstyled paragraph measures off. Get the fallback right and the advance wrong and a styled string measures off. Add a font the developer installed and the profile claims something no stock machine has.
None of these blocks a request alone. Font signals are terms in a fingerprint hash, next to canvas, WebGL, audio, and math. What they contribute is a dense, OS-specific description of the text stack that cross-checks the operating system every other signal claims. Line up the set, the fallbacks, and the advances with one real machine, and the text stack corroborates the OS. Leave any of them crossed, and it argues with the rest of the profile, all the way down to a scaler that only exists on the server the browser is actually running on.
Scrapium is Scrapfly’s scraping browser, built to stay indistinguishable from real traffic. Our engineering blog goes deep on the rest of the stack.
DirectWrite: Microsoft’s text layout and glyph-rendering API on Windows, the scaler Chrome uses there. Glyph metrics come through
IDWriteFontFace; the advance is quantized on DirectWrite’s own grid. It is bound to the Win32 subsystem. Reference: DirectWrite. ↩︎TrueType hinting: bytecode instructions stored in a font that adjust glyph outline points onto the pixel grid at a given size, and can alter the glyph’s advance. The interpreter is part of the scaler, so the same font hinted by DirectWrite, Core Text, and FreeType can produce three different advances. ↩︎
Core Text: Apple’s text layout and font-access framework on macOS and iOS, the scaler Chrome uses on macOS. It returns advances byte-identical to DirectWrite for the same font and size. Reference: Core Text. ↩︎
FreeType: the open-source font engine Chrome links on Linux and Android. It scales advances at a pixels-per-em value snapped to a 1/64 grid, with its own hinting and rounding. Reference: FreeType. ↩︎
HarfBuzz: the text-shaping engine Chromium uses to turn a character run into positioned glyphs. It requests glyph advances through a callback table so the host can supply them from its own scaler. Reference: HarfBuzz. ↩︎
Skia: Chromium’s 2D graphics library.
SkScalerContextis its per-platform font-scaler abstraction, backed by FreeType, DirectWrite, or Core Text depending on the OS. Reference: Skia. ↩︎queryLocalFonts: a Local Font Access API method returning the user’s installed fonts, gated behind a permission prompt a scraper avoids triggering. Reference: MDN. ↩︎FontFace/local(): the CSS Font Loading API. AFontFacewith alocal(...)source resolves against installed fonts, so a successfulload()means the family is present. Reference: MDN. ↩︎