Skip to content

The navigator.plugins array as a fingerprint and automation tell

· 21 min read
Copyright: MIT
The wordmark navigator.plugins[] in white monospace with the brackets and an underline bar in orange

For about fifteen years, the single most useful thing a tracking script could read out of a browser was the list of plugins. Not the user agent, not the screen size, not the fonts. The plugins. A 2010 study of nearly half a million browsers found the plugin list and the font list were the two most identifying attributes a page could collect, ahead of the user agent and well ahead of screen resolution. The reason was mundane: everyone’s machine had a slightly different pile of Adobe, Java, QuickTime, and RealPlayer versions bolted into it, and navigator.plugins handed that pile to any script that asked, in order, with version strings attached.

That world is gone. NPAPI is dead, Flash is dead, and the array that used to leak fifteen bits of entropy now returns the same five hard-coded entries on every Chrome install on earth, or an empty list, with nothing in between. Which would make navigator.plugins a closed chapter except for one thing: the array did not stop mattering when it stopped being diverse. It became a different kind of signal. A browser that returns the wrong shape here, an empty list where a populated one belongs, or a populated one assembled by hand in JavaScript, is announcing itself. The property that used to identify which human was browsing now helps identify whether anything human is browsing at all.

This post follows that arc. First the historical shape of the array and why it carried so much entropy. Then the long deprecation, NPAPI removal, Flash’s end, and the moment in 2021 when Chrome stopped reporting real plugins. Then the current state: the five names the HTML standard now hard-codes, the two MIME types behind them, and how navigator.pdfViewerEnabled replaced the whole mess. Then the automation angle, the empty array that gave away old headless Chrome, what the 2023 headless rewrite changed, and why every attempt to fake a PluginArray from JavaScript leaves a residue a detector can find. The through-line is that this one array has been a fingerprint, a deprecation case study, and an automation tell, in that order, and the last role is the one that still has teeth.

What the array used to be

A Plugin object in the old model described a single installed browser plugin, an actual binary loaded into the browser’s process to render content it could not render itself. Each one carried a name, a filename, a description, and a length, and behaved as an array-like collection of the MIME types that plugin handled. navigator.plugins returned a PluginArray, a live, ordered, array-like object you could index with plugins[0], query by name with namedItem("Shockwave Flash"), or walk with item(i). Alongside it sat navigator.mimeTypes, a MimeTypeArray of MimeType objects, each pointing back at the plugin that served it through an enabledPlugin reference. The two arrays cross-referenced each other. Ask navigator.mimeTypes["application/pdf"].enabledPlugin.name and you would get the name of whatever plugin claimed PDF rendering.

The richness was the problem. A representative desktop fingerprint from that era listed something like Chrome PDF Plugin, Chrome PDF Viewer, Native Client, Shockwave Flash, and trailed off into whatever else the user had installed, each with its own version string and file path. Multiply that across Adobe Reader point releases, Java runtimes, QuickTime, Silverlight, RealPlayer, and assorted toolbars, and the combinatorics got large fast. Two users sharing an identical plugin list in identical order was uncommon enough that the list alone often put a browser into an anonymity set of one.

navigator.plugins (PluginArray) navigator.mimeTypes (MimeTypeArray) [0] Shockwave Flash v32.0.0 [1] Chrome PDF Viewer [2] Java(TM) Plug-in 11.x [3] Native Client ... application/x-shockwave-flash application/pdf application/java-vm each MimeType.enabledPlugin points back to a Plugin. break the link and a script notices *The legacy model: two live arrays that reference each other by object identity. Faithfully reproducing that cross-reference is most of the difficulty in faking it.*

How much entropy this carried is not a guess. Peter Eckersley’s Panopticlick study at the EFF collected 470,161 browser fingerprints between late January and mid-February 2010, and concluded that the distribution carried at least 18.1 bits of entropy overall, enough that picking a browser at random gave you at best a one-in-286,777 chance of a collision. Within that, the paper singled out plugins and fonts as the two most identifying metrics, ahead of the user agent. Browsers that had Flash or Java were worse off still: 94.2 percent of them were instantaneously unique in the sample. The plugin list was not a side channel. It was the main channel.

The follow-up work made the decline legible. The AmIUnique project, which collected its own large fingerprint corpus around 2016, found the list of plugins remained the single most discriminating attribute on desktop browsers, while being nearly worthless on mobile, where plugins never really existed. They also charted the attribute losing its power in real time. The normalized entropy of the plugin list sat above 0.8 through Chrome 42. Then it fell off a cliff. By the time Chrome permanently removed NPAPI support in version 45, the figure had dropped below 0.5. The thing that had made the list so identifying, the long tail of installed binaries, was being pulled out from under it.

How NPAPI and Flash killed the diversity

The plugin list was only ever as diverse as the plugin ecosystem behind it, and that ecosystem was a security liability the browser vendors spent the better part of a decade dismantling. NPAPI, the Netscape Plugin Application Programming Interface, was the mechanism that let those binaries run. It dated to the 1990s, it gave plugins broad access to the machine, and it was a perennial source of crashes and exploits. Google announced its intent to remove NPAPI from Chrome in 2013, began disabling it by default through 2014 and 2015, and finished the job in Chrome 45 in September 2015. Once NPAPI was gone, the long tail of third-party plugins simply could not load, and the list collapsed toward whatever Chrome shipped internally.

Flash outlived NPAPI by a few years on a special exception, sandboxed and click-to-play, but Adobe announced its end of life in 2017 and the industry coordinated a hard shutdown at the end of 2020. With Flash gone, the last universally installed, version-varying entry left the array. What remained were a handful of built-in Chrome components that every copy of Chrome carried identically: the internal PDF viewer, exposed under a couple of names, and Native Client, which Chrome was itself in the process of deprecating. By 2020 the array no longer distinguished one Chrome user from another. It distinguished Chrome from Firefox from Safari, and that was about it.

At which point the array was carrying almost no entropy and a non-trivial amount of risk. It existed mainly so that legacy code could keep doing two things, probing for Flash support and fingerprinting, and the first of those was now meaningless. The honest move was to stop reporting plugins entirely.

2020 to 2021: Chrome empties the array

That is what Chromium proposed. In an Intent to Ship thread on the blink-dev list dated January 15, 2021, Mason Freed laid out the plan to return empty arrays for navigator.plugins and navigator.mimeTypes, with the rationale stated plainly: these APIs were used primarily for probing for Flash player support, now pointless with Flash removed, or for fingerprinting. The change rode along with the broader Flash teardown and shipped in Chrome 90, which reached beta on March 11, 2021 and stable shortly after. For a stretch of 2021, a freshly updated Chrome returned navigator.plugins.length === 0.

This was tracked upstream in the HTML standard. The WHATWG issue that kicked off the cleanup, opened by Domenic Denicola in October 2020, asked the broader question of whether the entire plugins machinery, the NavigatorPlugins interface, the Plugin and MimeType types, plugin document handling, could be removed from the platform outright. Some of it could. But the empty-array approach immediately ran into the compatibility wall that had kept the array alive in the first place.

Several large sites used the plugin list not to enumerate plugins but as a feature test. They walked navigator.plugins looking for a PDF viewer string, and if they did not find one, they concluded the browser could not display PDFs inline and changed their behavior, often by forcing a download instead of an inline render. An empty array told those sites the browser had no PDF viewer, which was false, Chrome’s internal viewer was right there, and the user experience regressed. The clean removal broke real pages.

The current state: five names, frozen by the spec

The resolution was a compromise that is slightly absurd when you look at it directly, and entirely sensible once you understand the constraint. Rather than return nothing, or return the truth, the HTML standard now specifies that a browser supporting inline PDF viewing must report exactly five plugins, with exactly these names, in this order: PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, and WebKit built-in PDF. The spec is candid about why those particular strings: they were chosen based on evidence of what websites historically search for, and thus what is necessary for user agents to expose in order to maintain compatibility with existing content. Every browser pretends to have all five of its competitors’ PDF viewers because some site somewhere checks for each name, and the cheapest way to satisfy all of those checks at once is for everyone to claim everything.

Behind the five plugins sit exactly two MIME types, application/pdf and text/pdf. Each MimeType object’s enabledPlugin getter is defined to point at the first plugin in the list, the generic PDF Viewer, regardless of which MIME type you queried. The cross-reference that used to encode real installation facts is now a constant. The whole structure is a fixed lookup table that the same in every conformant browser.

~2014 Chrome (per-machine, high entropy) Shockwave Flash 15.0.0.189 Chrome PDF Viewer Java(TM) Plug-in 1.8.0_25 Native Client Silverlight Plug-In 5.1 ... order and versions vary per machine 2024+ Chrome (identical everywhere) PDF Viewer Chrome PDF Viewer Chromium PDF Viewer Microsoft Edge PDF Viewer WebKit built-in PDF same five names, same order, zero entropy the array stopped identifying the user. now its only job is to be present and well-formed. *The array went from a per-machine identifier to a constant. That constancy is exactly what makes its absence, or its hand-built imitation, conspicuous.*

The replacement API for the legacy feature test is navigator.pdfViewerEnabled, a boolean getter that returns whether the browser can display PDFs inline. The HTML standard recommends using it instead of inferring PDF support from the plugins list, and the plugins list is documented across vendor references as a deprecated property that may eventually be removed. Firefox followed Chrome here. Bug 1720353 implemented navigator.pdfViewerEnabled along with the same hard-coded navigator.plugins and navigator.mimeTypes lists, shipping in Firefox 99 in 2022, so that the two engines now expose the same five names and the same boolean. Safari exposes the boolean as well. The convergence is the point. Where the array once differentiated browsers, it now deliberately does not.

This is the state of things in 2026. A normal desktop Chrome, Edge, or Firefox returns the five names above and pdfViewerEnabled === true. A build with the internal PDF viewer disabled returns an empty array and pdfViewerEnabled === false. There is no longer a middle ground where the array reflects what is actually installed, because nothing is actually installed; the entries are spec text, not software.

Where automation enters

Now the useful part. The same standardization that drained the array of fingerprinting entropy turned it into a consistency check, and consistency checks are where automated browsers historically struggled.

When Chrome first shipped a headless mode in 2017, the headless build behaved differently from the headful one in a long list of small ways, and the plugin array was near the top of every detection writeup. Headless Chrome returned an empty navigator.plugins. Headful Chrome of the same era did not. So the check was a one-liner that everyone copied: if (navigator.plugins.length === 0) { /* possibly headless */ }. It was crude, it produced false positives on locked-down or plugin-stripped real browsers, and it was trivial to read, which is exactly why it spread. For a couple of years, an empty plugin array was one of the cheapest headless tells available, sitting alongside the HeadlessChrome user-agent token and the missing window.chrome object as part of the standard first-pass battery.

The complication is that the meaning of an empty array changed underneath the detectors. After Chrome 90 emptied the array for everyone in 2021, navigator.plugins.length === 0 no longer separated headless from headful, because every Chrome returned zero for a while. Then the spec settled on the five hard-coded names, and the relationship flipped again: a current headful Chrome returns five, so an empty array is once more anomalous, but for a different reason than in 2017. The bare length check has been right, then wrong, then right again, which is a decent argument for not building detection on a single integer that the platform keeps redefining. The lifecycle of a stealth patch is full of signals like this, where the ground shifts under both the detector and the evader on a multi-year clock.

What the 2023 headless rewrite changed

The bigger shift came with the new headless mode. For years the headless build was a separate, lighter code path inside Chromium that diverged from the real browser in observable ways. The plugin array was one such divergence; there were many others. Starting around late 2022 and documented widely in early 2023, Chrome shipped --headless=new, a headless mode that runs much closer to the real browser, sharing far more of the headful code path. The practical effect, as the bot-detection researcher Antoine Vastel summarized when the new mode landed, is that the new headless Chrome now exposes the same plugins as a headful Chrome, the same for the mimeTypes, and carries a real window.chrome object, which together erased several of the classic first-pass tells at once. He titled the writeup around the observation that the new headless mode had a near-perfect browser fingerprint.

So the empty-plugins tell, in its original form, is dead twice over: once because the platform emptied the array for everyone in 2021, and again because the new headless mode no longer empties it relative to headful. A modern automation stack running new headless Chrome with the standard flags reports the canonical five PDF plugins without anyone lifting a finger. The detector who is still grepping for navigator.plugins.length === 0 is checking a box that genuine new-headless Chrome passes cleanly. This is the same lesson the HeadlessChrome user-agent token story teaches: the obvious tells get fixed upstream, and detection moves to subtler ground.

What this does not mean is that the array became useless to detectors. It means the interesting case shifted from old, unpatched automation, which now reports plugins correctly on its own, to automation that is spoofing the array, either to mimic an older browser, to forge a non-Chrome profile, or because an anti-detect tool is rewriting the whole navigator surface. The moment a tool stops letting the browser report its real plugins and starts assembling a PluginArray by hand, a new and much more specific set of tells opens up.

Why faking the array is fragile

To understand why hand-built plugin arrays leak, you have to look at what a real navigator.plugins is at the object level. It is not a JavaScript array and it is not a plain object. It is a host object backed by the browser’s C++ binding layer, an instance of the native PluginArray interface, whose entries are native Plugin instances, whose methods are native code. Every one of those facts is independently observable from JavaScript, and a fabrication has to reproduce all of them to pass. Most fabrications reproduce some and miss the rest.

The most direct check is navigator.plugins instanceof PluginArray. On a real browser this is true, because the object’s prototype chain actually runs through PluginArray.prototype. A naive fake, an array of plain objects assigned to navigator.plugins, has its prototype chain rooted in Array.prototype or Object.prototype, and the instanceof test returns false. The same applies one level down: each entry should satisfy plugins[0] instanceof Plugin and each MIME type mimeTypes[0] instanceof MimeType. A detector that runs these four checks is asking whether the objects are what they claim to be, not merely whether they have the right property names, and prototype identity is far harder to forge than property values.

three layers a fake has to pass, hardest at the bottom plugins[0].name === "PDF Viewer" property values — easy to fake, everyone gets this right plugins instanceof PluginArray prototype identity — a plain object or Proxy fails this item.toString() === "function item() { [native code] }" native-code residue — a JS shim toString reveals itself match the strings and you still owe the prototype chain and the [native code] tells *Faking the values is the easy 80 percent. The prototype identity and the native-function residue are the 20 percent that catches most spoofs.*

Below the prototype layer sit the function objects. A real PluginArray has item, namedItem, and a length getter, all of them native. Call Function.prototype.toString on a native method and you get the canonical function item() { [native code] }. Call it on a JavaScript replacement and you get the actual source, an arrow function, a wrapped closure, whatever the spoof used. This is the same family of tell that catches a patched navigator.permissions.query, and the detection community has built it into a general lie-detection pattern: walk the suspect API’s functions, stringify each one, and flag any that should be native but are not, or any whose toString has itself been overridden to hide the residue, which CreepJS catches by checking the length and behavior of toString itself. Discussed at length in the companion post on the navigator.permissions query inconsistency, the principle generalizes: a spoofed native API tends to betray itself in the function objects, not the data.

There is a subtler tell in the property descriptors. On a real browser, navigator.plugins is an accessor backed by the prototype, and Object.getOwnPropertyDescriptor on the relevant prototype returns a getter implemented in native code. A spoof that reassigns the property, or redefines it with a JavaScript getter, changes the shape of that descriptor, where the property lives on the chain, whether it is configurable, whether its getter stringifies to native code. A detector comparing the descriptor against the known-good shape for the running browser version sees the difference. None of these checks reads the plugin names at all. They interrogate the machinery, and the machinery is what a JavaScript-level fake cannot fully reconstruct.

The cross-reference adds one more axis. Recall that each MimeType.enabledPlugin must point at a Plugin that actually appears in navigator.plugins, by object identity, not by equal value. A faithful fake has to wire those references so that mimeTypes["application/pdf"].enabledPlugin === plugins.namedItem("PDF Viewer") holds as a strict-equality identity check, and has to keep them consistent across item, namedItem, and integer indexing, and across the iteration order the real browser uses. The stealth ecosystem knows this. The widely used puppeteer-extra stealth plugin’s navigator.plugins evasion exists precisely to rebuild both arrays as functional mocks that cross-reference each other, with special handling so that shared MIME types resolve to a single shared object rather than two copies, because a duplicated object identity is itself a tell. It is careful work. It is also, by construction, JavaScript pretending to be a native binding, which means it inherits every prototype-and-toString weakness above no matter how carefully the data is assembled. Why this category of patch keeps losing the longer game is the subject of the post on why stealth plugins lose.

The empty-array case is its own signal

One spoofing strategy avoids all of this by going the other direction: report an empty array. If you cannot faithfully build five native plugins, return zero and claim the build has no inline PDF viewer. The catch is that an empty array is now its own anomaly, because it has to be consistent with everything around it. A real browser with an empty plugin list also returns navigator.pdfViewerEnabled === false, because the same internal flag drives both. A spoof that empties navigator.plugins but leaves pdfViewerEnabled at its default true, or vice versa, has produced a contradiction between two surfaces that a real browser keeps in lockstep, exactly the class of inconsistency that detectors love because it cannot be reasoned away as configuration. And an empty array on a user agent claiming to be ordinary desktop Chrome is itself suspicious, because ordinary desktop Chrome ships the viewer enabled and returns five. The empty case only looks normal when every adjacent signal agrees that PDFs are off, and getting all of those to agree is the same coordination problem as faking the populated case, just inverted.

This is the general shape of modern fingerprint-based detection, and the plugin array is a clean miniature of it. No single value is decisive anymore. The value of navigator.plugins.length on its own tells you almost nothing, because the platform has made it the same for everyone and then changed what everyone means by it twice. What carries signal is the joint distribution: the length, the names and their order, the prototype identities, the native-code residue of the methods, the property descriptors, the MIME cross-references, and the agreement of all of that with pdfViewerEnabled, the user agent, and the rest of the navigator surface. A real browser satisfies every constraint for free because it is one coherent program. A spoof satisfies the ones its author thought of. The detector’s job is to find the constraint the author missed, and the plugin array offers an unusually large number of them for an object that, on the surface, holds five constant strings.

The closing irony

The plugin array spent fifteen years as the most identifying thing a browser would tell you about its user, and the platform spent five years deliberately killing that property, removing NPAPI, ending Flash, emptying the array, then freezing it to a constant in the HTML standard so that no two browsers could differ. The privacy win was real. A list that once put most browsers into an anonymity set of one now contributes zero entropy to distinguishing one Chrome user from another.

But entropy and forgeability are different things. By collapsing the array to a fixed, spec-mandated, native-backed structure that every real browser reproduces bit for bit, the same change that erased its fingerprinting value gave it a sharp second life. There is now exactly one correct way for navigator.plugins to look, down to the prototype chain and the [native code] strings, and anything that deviates, an empty array on a desktop user agent, a populated array whose entries fail instanceof, a item method that stringifies to JavaScript, a pdfViewerEnabled that disagrees with the list, is deviating from a target with no variance to hide in. The array stopped telling sites who you are. It started telling them, with the unusual confidence that comes from a zero-entropy reference, whether you are a browser at all. Standardizing a fingerprint out of existence did not retire it. It sharpened the part that was left.


Sources & further reading

Further reading