Font and Plugin Enumeration Fingerprinting in Headless Environments

Attackers bypass bot detection by matching fonts to claimed operating systems in headless browsers.

Cover illustration for “Font and Plugin Enumeration Fingerprinting in Headless Environments”
Written by
Priya Subramanian
Published
October 9, 2026
Reading time
10 min read
Sources cited
4 sources ↓

Font and plugin enumeration rank among the oldest tricks in the browser fingerprinting toolkit, and detection systems still lean on them for a simple reason: both reveal something true about the software environment underneath the browser, not only about the browser itself. A user never types out a list of installed fonts or hands over a plugin inventory on purpose. The browser exposes that information anyway, as a side effect of normal rendering and API behavior, and the resulting data stays stable across sessions in a way cookies never do. Cookies get cleared. The font set installed by a design studio's IT department, or the PDF-handling plugins baked into a given engine build, do not change from one page load to the next.

The practical stakes have shifted as automation has gotten better at impersonation. Headless browsers built on Playwright, Puppeteer, Selenium, or any of the commercial headless APIs now run genuine Chromium, Firefox, or WebKit engines. The old surface tells, a user-agent string that says "Python," a missing Accept-Language header, have largely disappeared from competent setups. That pushes the burden of detection down into the rendering and API layer, where font and plugin signals live, keeping these two aging techniques load-bearing.

Font enumeration: text-rendering measurement as a side channel

Font enumeration doesn't work by asking an operating system to hand over its installed font list. No such polite API exists for a web page to query. Instead, it works by measuring the physical output of text rendering and inferring font presence from that output, a side channel built on how browsers lay out type.

The mechanism is mechanically simple. A script creates a hidden element, a div or a canvas, somewhere in the DOM where the user never sees it. It renders a string of text in a candidate font family, with a known fallback font specified alongside it. If the named font is actually installed, the browser uses it, and the rendered text occupies a specific width and shape. If the font isn't present, the browser falls back, and the measured output differs. Running this test across hundreds of font names turns the pattern of hits and misses into a fingerprint. TrustMyIP's font leak tester demonstrates the mechanism directly: CSS font introspection paired with JavaScript's measureText renders invisible text with a fallback font, then checks hundreds of font names one at a time. A shift in rendered text width means that font is installed on the machine. You can also use the FontFaceSet interface and related browser APIs to query font availability more directly, but they still depend on the rendering engine's own answer.

What comes out the other end is more than a yes/no list. Scrapeless's font fingerprinting breakdown points out that the data collected includes font family, font name, PostScript name, style, and font size, and the combination of those fields is specific to a user's operating system, installed software, regional language packs, and personal setup. A font set alone can separate Windows from macOS from Linux. It can separate a bare consumer laptop from a machine running design or development software that installs its own typefaces as a byproduct of installation.

That said, font enumeration's value as a raw identifier has weakened over time. Windows 11, recent macOS releases, and modern Linux distributions have standardized their default font sets more than earlier versions did, so the variation between machines on the same platform has narrowed. Most modern sites rely on web fonts delivered over the network rather than triggering system font rendering at all, so the side channel often goes unused in ordinary browsing. Firefox's privacy.resistFingerprinting setting, for example, now returns a fixed, generic font list no matter what's installed. The signal hasn't disappeared, but its job has changed: instead of distinguishing one real user from another, it increasingly functions as a check for whether a browser's font behavior matches the operating system it claims to be running on.

Plugin Enumeration: API Exposure as an Inventory Signal

In the years before browsers hard-coded the plugin list, navigator.plugins told a detector something real and specific about a user's machine. The array reflected actual third-party software installed on that system, things like Flash, Java, Silverlight, and QuickTime, alongside various PDF viewers, forming a set that changed slowly and varied widely from one person to the next. That combination of stability and individuality made it a strong fingerprinting input in its own right.

Chrome 90 and the refinements that followed changed what the array contains. navigator.plugins and navigator.mimeTypes stopped reflecting installed software and started returning a fixed, hard-coded set: five entries, named PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, and WebKit built-in PDF. Every real Chrome installation returns that same list now, no matter what the user has installed. Firefox followed the same path, shipping navigator.pdfViewerEnabled alongside matching hard-coded plugin and mimeType lists in Firefox 99, so the two engines converged on the same five names.

That change drained the array of its old value as a way to pick one user out from millions of others. But it handed detectors something else just as useful. Because the five-entry list is now a known constant for genuine Chrome, any departure from that constant signals something worth investigating. A real browser returns a specific, predictable list, whether it's Chrome or Firefox. An environment that returns zero entries, a different count, or names that don't match the canonical five stands out immediately because it fails to look like a standard browser. The plugin array shifted from an entropy source, useful for telling users apart, into a coherence check, useful for telling browsers apart from things pretending to be browsers.

How headless environments fail font enumeration

Headless browsers fail font enumeration because the environment the engine is actually rendering inside lacks the font infrastructure a genuine user's machine has, and that gap appears directly in the output.

Consider a headless Chromium instance running on a bare Linux server, the default setup for most cloud automation and scraping infrastructure. That server carries the default Linux font set, and in a lot of minimal configurations, it carries almost no system fonts. When a script runs the standard font enumeration routine against that instance, it gets back a font list that looks nothing like what a Windows or macOS machine would produce. If the same browser session is presenting a user-agent string claiming to be Chrome on Windows, the mismatch is glaring. Anti-bot systems built around this kind of cross-layer check treat a realistic User-Agent paired with a non-matching rendering profile as an immediate red flag. If a headless Chrome running on a cloud VM claims Windows in its user-agent but returns a Linux or near-empty font set through enumeration, it fails the coherence check that font enumeration has come to serve.

The problem runs deeper than the font list itself. Real operating systems apply subpixel antialiasing and hinting rules specific to their platform, because that's part of how they render crisp text on a screen. Headless environments running through a software renderer such as SwiftShader or llvmpipe, common substitutes when no GPU is present, produce different pixel-level text rendering output than genuine hardware-backed rendering does. Canvas-based font rendering tests can pick up on that difference even in cases where the enumerated font list alone looks plausible. Two separate signals, the list of fonts and the physical appearance of rendered text, have to agree with each other, and headless infrastructure often can't make them agree without deliberate, careful configuration.

Headless environments and plugin enumeration failure: the empty-array tell and its evolution

Headless browsers have given themselves away through navigator.plugins in at least three distinct phases, and each phase changed what a detector had to check for.

In the earliest phase, headless Chrome simply returned an empty array where a real browser would return populated plugin data. That was close to a free tell: any script checking navigator.plugins.length and finding zero could flag the session as automated with very little additional work. Then the platform itself changed the ground under that check. Chrome 90 and 91 hard-coded the plugin list to five canonical PDF-related entries for every real installation, so the meaningful difference between individual human users vanished, but headless Chrome still lagged behind at first in replicating that exact array.

The 2023 Chrome headless rewrite closed that gap. Modern headless Chrome, run with standard flags, now reports the same canonical five PDF plugins as headful Chrome, the same list hard-coded by the HTML specification. That means the naive check, navigator.plugins.length === 0, no longer catches anything. An operator running stock headless Chrome passes a simple length or presence check without doing a single thing to evade detection, simply because the browser vendor closed that gap for everyone.

Detection built around that single naive check is dead. What replaced it is a check built around the relationship between several independent signals. The open-source HackingLZ fingerprint_js tool shows this approach in practice: it runs separate checks across navigator.plugins, navigator.platform, navigator.productSub, and language ISO codes, examining whether those values are mutually consistent with each other rather than whether any single one of them looks correct in isolation. A session can pass every individual check and still fail because the combination doesn't hang together the way a real browser's combination does.

A further layer concerns how automation scripts patch navigator.plugins itself. When an automation script patches a plain JavaScript object into navigator.plugins to simulate the canonical five entries, that object does not behave like the real thing structurally. Running p instanceof Plugin against it returns false, because the patched object never inherited from the native Plugin prototype the way a genuinely returned object does. Real browsers also maintain a bidirectional reference between navigator.plugins and navigator.mimeTypes automatically, and spoofed environments built from JavaScript patches often break that link without the operator realizing it. Tools like playwright-stealth patch navigator.plugins and navigator.mimeTypes among other properties, and the values they produce look correct when inspected directly. They fail the relational checks that compare those values against each other and against their prototypes, because the patch applies a mask and doesn't rebuild the underlying structure.

Camoufox, maintained originally by daijro and now by Clover Labs (version 0.5.6, released September 2026), takes a different approach to the same problem. Instead of injecting JavaScript after the page has loaded, Camoufox modifies Firefox's own C++ source code, so the values returned to a page originate from the engine itself. That approach sidesteps the entire class of prototype and reference failures that JavaScript-based stealth patches run into, because there's no patch for a detector to find residue from.

The plugin signal, then, didn't disappear as a detection vector. The arms race over navigator.plugins has shifted from checking whether one value exists to checking whether a whole family of related values, plugin entries, platform strings, mimeType references, and prototype chains, remain internally consistent with each other. Evasion tooling now has to get every related piece right at once.

Why JavaScript-layer patches cannot fully resolve either failure mode

Font and plugin evasion share a structural weakness when you attempt them through JavaScript injection, and it comes down to timing. A JavaScript patch runs inside the page only after the browser engine has already established the real, native environment underneath it. That means anything checking prototype chains, internal reference consistency, or actual rendering output is testing against the underlying reality of the engine, not against whatever the patch tried to present instead.

Font enumeration shows this clearly. A script can inject a plausible-looking list of fonts into whatever API a page queries, but it cannot change the actual text-rendering output the graphics stack produces when it draws characters to a canvas. Canvas rendering checks and subpixel measurement checks read from the renderer itself, not from the patched API surface, so the two signals can diverge even when the patch looks convincing on its own terms.

Plugin enumeration shows the same pattern from a different angle. A JavaScript object patched into navigator.plugins to mimic the canonical five-entry list doesn't inherit from the native Plugin prototype that a real browser attaches automatically, so it fails an instanceof check that costs a detector almost nothing to run. The same patch can also break the bidirectional reference between navigator.plugins and navigator.mimeTypes that real browsers maintain as a structural property of the API, not as a visible value a patch script would think to replicate.

Camoufox stands as the honest exception to this pattern, instructive for how it avoids the problem. By modifying Firefox's C++ source directly, it produces values that originate from the engine itself, with nothing for a prototype check or a reference check to catch, because there's no separate patch step for those checks to expose; durable evasion for these two specific signals requires working at the engine level, below the JavaScript surface.

The detection boundary above the font and plugin layer

Font and plugin enumeration haven't lost their relevance so much as they've been demoted to a specific role within a larger detection stack. Both now function as low-cost, fast preliminary gates, filters that catch unsophisticated automation cheaply before a detector spends more computational effort on more expensive signals like rendering and graphics fingerprints or network-layer fingerprinting. A session that fails a font or plugin coherence check gets flagged immediately and never reaches those costlier checks.

What the two signals no longer do is serve as a final verdict on their own. Passing a font check and passing a plugin check tells a detector that a session cleared the cheapest tier of scrutiny, not that the session is unquestionably genuine. The layered structure rewards precision over camouflage: a stable, internally consistent environment, whether produced by a real user's machine or by engine-level tooling like Camoufox, satisfies these checks because coherence is what real browsers produce by default, not because anyone engineered a clever trick to fool a specific test. For anyone evaluating automation tooling or detection systems built around these signals, the font and plugin layer marks the entry gate to a much deeper stack, not the whole of it.

Methodology & sources

  1. Font Leak Tester - Free Browser Font Fingerprinting Check

    Provided the concrete demonstration of CSS font introspection paired with JavaScript's measureText to detect installed fonts via width shifts.

  2. FYI: changes to navigator.plugins[] enumeration to reduce fingerprinting

    Provided context on Firefox's move to hard-coded plugin and mimeType lists in Firefox 99, converging with Chrome's approach.

  3. New headless Chrome has been released and has a near-perfect browser fingerprint

    Informed the discussion of the 2023 headless Chrome rewrite closing the gap between headless and headful plugin reporting.

  4. Detecting Headless Chrome's Puppeteer Extra Stealth Plugin with JavaScript Browser Fingerprinting

    Informed the explanation of how JavaScript-patched navigator.plugins fails prototype and reference checks that real browsers pass.

Priya Subramanian