If your anti-detect kernel spoofs the “installed fonts” list by patching document.fonts and calls it done, you are already caught. The dominant font-enumeration technique in the wild never touches a font API. It draws text and measures how wide it comes out. We watched 32 detection sites during a full engine-level recon on 2026-07-14, and the pattern held. Fonts are a top-tier signal, and the primary way sites read them is layout geometry rather than enumeration.
Fonts are high entropy and that cuts both ways
The installed-font set varies by OS, OS version, region and installed applications. Office drops a pile of fonts. A Chinese Windows box carries SimSun and Microsoft YaHei; a stock US machine does not. That variance is why fonts rank among the strongest passive signals. AmIUnique measures the font list’s rarity against a real population, and a distinctive set pins you down fast.
Rarity cuts both directions. A persona that reports too few fonts, or an implausible combination such as a US-locale machine that somehow also has the full CJK stack, is as identifying as one that reports too many. What you want is a coherent, common set that matches the persona’s platform. Not the smallest possible list. Miss that and the geometry gives you up.
The three detection paths
Every font probe we observed falls into one of three buckets, ordered by how often we saw them:
| Path | Mechanism | Where it fires | Prevalence |
|---|---|---|---|
| Measurement (layout-measure) | Insert a span with font-family: <candidate>, monospace, read offsetWidth/offsetHeight, compare to the fallback |
BrowserLeaks Fonts, AmIUnique, BrowserScan, Iphey, DataDome, Whoer, Vastel-family | Dominant |
| API (FontFaceSet) | document.fonts + FontFaceSet.check() / FontFace.load() |
Iphey, CreepJS | Secondary |
| Canvas (measureText) | CanvasRenderingContext2D.measureText for text width / glyph metrics |
Iphey, Vastel-family | Secondary and feeds a combined render+font fingerprint |
The volume gap is not subtle. In a single page load, BrowserLeaks Fonts fired offsetWidth 3,829 times and offsetHeight 3,829 times, with essentially no font-API calls at all. AmIUnique fired the measurement path 3,136 / 2,965 times; BrowserScan 2,500 / 2,366; Iphey 311 each. The API path is a rounding error by comparison. Iphey’s FontFaceSet.check() and FontFace.load() sat around 310 each, and CreepJS’s FontFace.load around 51. Canvas measureText came in near Iphey ×311 and Vastel-family ×337.
Put yourself in the spoofer’s seat. Patch only the font API and you have spent your effort on the path that fires ~310 times while ignoring the one that fires ~3,800 times on the same page.
Why the measurement method works
Pick a candidate font. Render a long test string in font-family: 'Candidate', monospace. If Candidate is installed, the browser renders it, the string takes on that font’s advance widths, and the box measures some width W1. If Candidate is not installed, the browser falls through to monospace and the box measures the fallback width W0. Compare W1 to the baseline from rendering the same string in bare monospace. Different width means the font is present. Same width means it is absent or metrically identical to the fallback, which is rare for a long, glyph-varied string.
No API is consulted. This is pure layout, and layout reflects the actual font the rasterizer chose, which is the ground truth a kernel has to keep consistent. It is also cheap and unfakeable at the JavaScript boundary. A site does not ask the browser whether a font is installed. It forces the browser to use the font and reads back a physical consequence. There is no API surface to lie through, because the answer is computed downstream of every API by the layout engine itself. The sites probe by side effect rather than by query, and that inversion is what makes it the default enumeration method across sites that otherwise share no code.
Here is a compact probe of the kind these sites ship:
// Baselines: measure a long, glyph-varied string in each generic family.
const TEST = "mmmmmmmmmmlliWWWQQ汉字gjpqy0123456789";
const BASES = ["monospace", "serif", "sans-serif"];
const SIZE = "72px";
function makeSpan(family) {
const s = document.createElement("span");
s.style.cssText =
"position:absolute;left:-9999px;top:-9999px;" +
"white-space:nowrap;line-height:normal;" +
`font-size:${SIZE};font-family:${family};`;
s.textContent = TEST;
document.body.appendChild(s);
return s;
}
// Record fallback dimensions per generic family.
const baseline = {};
for (const b of BASES) {
const el = makeSpan(b);
baseline[b] = { w: el.offsetWidth, h: el.offsetHeight };
el.remove();
}
// A font is "installed" if, layered in FRONT of a base family,
// it shifts the measured box away from that base's fallback size.
function isInstalled(font) {
return BASES.some((b) => {
const el = makeSpan(`'${font}',${b}`);
const changed =
el.offsetWidth !== baseline[b].w ||
el.offsetHeight !== baseline[b].h;
el.remove();
return changed;
});
}
const CANDIDATES = ["Arial", "SimSun", "MS Gothic", "Microsoft YaHei", "Segoe UI"];
const detected = CANDIDATES.filter(isInstalled);
Real implementations optimize this. They reuse one node, batch reads to avoid layout thrash, and throw hundreds of candidates through it. The core is exactly the width/height comparison above.
Why this is hard to spoof coherently
The measurement path sets a brutal acceptance bar for any kernel claiming to control the font dimension. Rewriting a “font list” is not enough. The rendered geometry has to agree with the list you claim:
- A blocked font’s span must measure identical to the fallback: same
offsetWidth, sameoffsetHeight, samemeasureTextadvance. If the glyphs still render because the font is physically installed on the host, the box width betrays it no matter what the API says. - An allowed font must measure its real glyph metrics, the genuine advance widths for that face, because those metrics are themselves checked against known values and cross-checked via
measureText.
That is a two-sided constraint on the rasterizer, not on a JavaScript getter. Our own recon note put it bluntly: a font patch that only touches the enumeration API, without making offsetWidth / offsetHeight / measureText results consistent with the spoofed set, “gets exposed on the spot by the measurement method.” The self-test we adopted is direct. Inject a candidate-font span, measure width and height, and confirm a blocked font falls back while an allowed font renders its true metrics.
There is a subtler trap. The three paths must agree with each other. If the measurement path says a font is absent (falls back) but measureText returns that font’s real advances, the inconsistency is itself the tell. Coherence across offsetWidth, FontFaceSet and measureText is the whole game.
One signal, many surfaces
“Installed fonts” sounds like one fact. It is several surfaces. Counting only what we saw fire in the wild, the same underlying signal is readable through:
offsetWidth/offsetHeighton measured spans (dominant),FontFaceSet.check()andFontFace.load(),CanvasRenderingContext2D.measureTextglyph metrics,- and, distinct from all of the above, the platform UI-font hint, the generic system UI font name a browser exposes (e.g. what CreepJS surfaces under “platform hints”).
That fourth surface bit us during our own end-to-end retest. After the offsetWidth path was handled, CreepJS platform-hints still reported Microsoft YaHei UI as the system UI font. It is a separate hint surface from the geometry path, and we logged it as an open to-do rather than pretending one patch closes the signal.
The CJK / host-OS leak
This is where the theory meets a recurring, real failure. Every commercial anti-detect browser we tested (bablo, AdsPower, CloakBrowser, VMLogin) leaked CJK fonts like SimSun, MS Gothic and Microsoft YaHei under aggressive offsetWidth enumeration. The reason is mundane and damning. The physical host was a Chinese Windows machine while the persona claimed to be a US user. The rasterizer renders SimSun because SimSun is genuinely installed, the span width shifts, and the measurement path reads it straight off the host OS. No API lie could have prevented it, because no API was involved.
In controlled anti-detect browser testing, all four browsers leaked SimSun / MS Gothic under aggressive offsetWidth enumeration. A spoofed US persona running on a real Chinese machine gives itself away through font geometry.
(controlled anti-detect browser testing notes)
Our consistency scanner treats this as a first-class inconsistency. The check is simple: CJK fonts present on a non-CJK locale is a host-OS region leak.
const CJK_FONTS = ['Microsoft YaHei', 'SimSun', 'SimHei', 'PingFang SC',
'Hiragino Sans', 'Meiryo', 'Malgun Gothic', 'Noto Sans CJK SC'];
const cjkPresent = client.fonts.detected.filter((f) => CJK_FONTS.includes(f));
const localeCJK = /(^|-)(zh|ja|ko)/i.test(client.intl.locale || navLang);
if (cjkPresent.length && !localeCJK)
add('cjk-fonts', 'CJK fonts vs locale', 'warn',
`CJK fonts (${cjkPresent.join(', ')}) are installed but locale is non-CJK ` +
`- may reveal the real host OS region.`, 6);
Related but distinct is the platform-hints UI-font leak. Among the four browsers, AdsPower, CloakBrowser and VMLogin all exposed Microsoft YaHei UI as the system UI font hint. Only bablo normalized it to Segoe UI, the correct default for a US Windows persona. Same root cause (a CJK host), two independent surfaces: the enumerable font set via geometry, and the generic UI-font name via hints. Fixing one does nothing for the other.
Takeaway
Fonts are a top-tier fingerprint, and the technique that reads them best ignores every font API you might think to patch. It renders text and measures the box. For a spoofer the acceptance bar is two-sided and layout-level. Blocked fonts must measure identically to the fallback, allowed fonts must measure their true metrics, and the offsetWidth, FontFaceSet and measureText paths must all tell the same story, as must the platform UI-font hint sitting off to the side. None of that helps if the host OS still has the wrong fonts physically installed. A US persona on a Chinese box leaks SimSun through geometry no matter how clean the JavaScript is. Patch the rasterizer rather than the getter, and match the host to the persona rather than the story it tells.