liarjs.dev
← Field notes
Navigator

The plugin face Android never had: five entries that kill a mobile persona

September 23, 2026 · 6 min read

Modern Chrome does not have plugins. What it has is a fixed list that the HTML standard requires it to pretend to have, so that legacy detection code keeps working: five Plugin entries, all of them aliases for the built-in PDF viewer, and two MIME types, application/pdf and text/pdf. Since Chrome 94 that list has been hard-coded, identical on every desktop installation, and completely uninteresting as an identity signal.

It is extremely interesting as a build signal, because Chrome on Android is compiled without the PDF viewer entirely. The build flag is not subtle:

enable_pdf = !is_android && !is_ios && …

No PDF viewer means no PDF plugin means no fixed list to expose. A real Chrome on a real Android phone reports:

navigator.plugins.length      // 0
navigator.mimeTypes.length    // 0
navigator.pdfViewerEnabled    // false

A desktop binary wearing a phone persona reports 5, 2 and true, because the identity layer has no idea the PDF viewer exists.

Two numbers and a boolean that a mobile persona cannot change

Three reads, one line

The whole check fits on a line and needs no reference data:

const mobile = /Android/.test(navigator.userAgent);
const desktopPdfFace = navigator.plugins.length > 0 || navigator.pdfViewerEnabled;
if (mobile && desktopPdfFace) { /* this is not a phone */ }

There is no clever inference here, no scoring, no threshold. Two mutually exclusive facts about one browser, both read from properties that have been stable for years.

What makes it worth a post is not its sophistication but its position. This is the cheapest possible detection of the most expensive possible spoof. Someone can have replayed a real device’s WebGL surface, matched its screen and dpr and touch points, captured its fonts, aligned its client hints, and lost to navigator.plugins.length.

The desktop version of the same question

The mirror image of this check runs against desktop personas and is a version signal rather than a platform one.

Before Chrome 94 the plugin list was a real, variable list: Chrome PDF Plugin, Chrome PDF Viewer, Native Client, and whatever else the installation had, with MIME types including application/x-google-chrome-pdf. From 94 onward it is the fixed five-entry list, application/x-google-chrome-pdf is gone, and application/pdf is the MIME type to look for.

So a browser whose User-Agent says Chrome 151 while its plugin list uses the pre-94 model has an identity layer that was written against an older Chrome and never revisited. The specific tells are the legacy plugin names, the presence of application/x-google-chrome-pdf, and a plugin count that is anything other than 0 or 5.

Both directions come down to the same thing. The plugin face is not a fingerprint dimension any more. It is a version and platform assertion that the browser makes accidentally, which is precisely what makes it hard to keep aligned.

Why this class of bug is systematic

The PDF viewer is not the only thing a build flag decides. It is the most visible, because it surfaces through three properties that every fingerprinting script has read since 2010.

The pattern is that identity systems are written by thinking about what a device is - a phone has a small screen, a touchscreen, an ARM chip, a mobile GPU - and build differences are not on that list. Nobody sits down to spoof a phone and thinks “and its browser was compiled without a PDF renderer”. The result is that the identity layer covers everything a human would describe about the device, and nothing about the binary describing it.

That is why the fix has to be structural rather than a patch per property. A persona with deviceType: android should be a switch that drives every downstream difference, including the ones that are not about the device at all: the plugin face, the interface set, the notification constructor, the presence of screen orientation events. Adding the plugin list to a list of overrides fixes the plugin list. Deciding that “Android” means “behave as an Android build” is what fixes the class.

Verifying it

The self-test is short enough to run in a console on whatever you are trying to make convincing:

({
  plugins: navigator.plugins.length,
  mimeTypes: navigator.mimeTypes.length,
  pdfViewer: navigator.pdfViewerEnabled,
  claimsAndroid: /Android/.test(navigator.userAgent),
})

For an Android persona the first three must read 0, 0, false. For a desktop persona on Chrome 94 or newer they must read 5, 2, true, and the five names must be the standard set rather than the legacy ones. Anything else is a finding.

It is also worth checking what happens with a real PDF. pdfViewerEnabled is supposed to describe whether the browser will display a PDF inline rather than download it, and a persona that reports false while the browser happily renders one inline has produced a contradiction the same way round as the notification constructor: the property says one thing, the behaviour demonstrates another. Detectors that go this far are rare, but the property is trivially cheap and the behaviour is not.

The wider set of build-flag tells

The PDF viewer is one entry in a longer list of things a build decides and an identity layer does not model. Once you go looking for the pattern, the same shape appears repeatedly:

  • Proprietary codecs. Chrome ships H.264 and AAC; a plain Chromium build does not. A browser whose User-Agent says Chrome and whose canPlayType returns empty for avc1 is a Chromium build wearing Chrome’s name, which is a common state for a fork that never enabled the proprietary codec flags.
  • Widevine. The DRM module is bundled with Chrome and absent from bare Chromium builds, and its presence is queryable through the encrypted media APIs.
  • ICU data. How many time zones and collations a build carries depends on which ICU data set was compiled in. A build with a trimmed data set answers Intl.supportedValuesOf('timeZone') with a shorter list than stock Chrome does.
  • Component-updated pieces. Several parts of Chrome arrive after installation rather than in the binary, and a fresh profile that has never contacted the update servers differs from one that has.

Each of these is a single read, each is stable, and none of them is a property that anybody thinks of as identity. That is the recurring theme: a persona models a device, and a browser is a build running on a device. The gap between those two nouns is where a surprising share of the checks in a modern detector live.

Where it fits

In a full scan against a desktop host running an Android persona, this check sits alongside the interface census, the font metric collapse and the multi-monitor flag. All four ask the same question from different angles: not “what does this browser claim to be” but “what was this browser built and configured as”.

None of the four is hard to check. Collectively they are hard to satisfy, because satisfying them means the persona has to be implemented at the level of the build rather than the level of the property list. A phone persona that gets all of them right is close to being an actual Android build, and one that gets none of them right is a desktop browser with a different User-Agent string.

Sources & further reading
  1. MDN, Navigator: pdfViewerEnabled
  2. HTML Standard, PDF viewer support and the fixed plugin list
  3. Chromium, pdf/features.gni
More field notes
← Back to field notes