Skip to content

The navigator object as a fingerprint: every property a detector reads

· 21 min read
Copyright: MIT
The wordmark navigator with an orange dot and underline, over a list of fingerprintable properties in grey monospace

Open a console on any page and type navigator. What comes back is a single object that a detector treats as a sworn statement about the machine underneath. The browser’s name and version, the operating system, the CPU core count, an estimate of installed RAM, the user’s language preferences, the vendor string, an ancient build date, and a boolean that says whether a WebDriver client is at the wheel. None of it is authenticated. Every field is a plain JavaScript property a script can read, and almost every field is a property an automation framework can overwrite. That is the whole tension. The navigator object is the easiest fingerprint surface to read and one of the easiest to fake, which is exactly why a serious detector never trusts any single property and instead checks whether the properties agree with each other.

That second part is where the real work happens. A spoofed userAgent that says Windows is trivial. A spoofed userAgent that says Windows, alongside a platform of Linux x86_64, an oscpu that Chrome should never expose, a deviceMemory of 6, and a vendor that is empty when it should read Google Inc., is a confession. This post is a property-by-property reference to what the navigator object exposes, what the legitimate values look like across Chrome, Firefox, and Safari, and the consistency relationships a detector mines when it suspects the object is lying.

The order here goes roughly from the loudest signals to the quietest. First webdriver, the one property that exists only to announce automation. Then the identity block: userAgent, platform, vendor, product, productSub, appVersion, and oscpu, the strings that should all describe the same browser. Then the hardware tells, hardwareConcurrency and deviceMemory, which are quantized in ways a spoof rarely respects. Then languages, which has to match an HTTP header the script cannot see. Then the modern replacement layer, userAgentData and the Client Hints it exposes. And finally the consistency engine itself, the cross-checks that turn a pile of individually-plausible values into a verdict.

Most of the navigator object was designed for feature detection and content negotiation. One property was designed for bot detection, and it sits right there in the open. navigator.webdriver is a read-only boolean defined by the W3C WebDriver specification, and its entire job is to let a co-operating browser tell the page that an automation client is in control. The spec language is explicit: it gives a standard way for user agents to inform the document that it is controlled by WebDriver so that alternate code paths can be triggered during automation. The property has been widely available across major browsers since May 2018.

On a human’s browser the property returns false. Under automation it returns true, and the conditions that flip it are specific. In Chrome the value becomes true when the browser is launched with --enable-automation, when it runs --headless, or when --remote-debugging-port is set to port 0. In Firefox it flips when the marionette.enabled preference is set or the --marionette flag is passed. Selenium, Puppeteer driving Chrome through the DevTools protocol, and Playwright all trip at least one of those conditions by default, which is why a freshly-launched automation session announces itself before a single line of detection script runs.

The obvious move from the automation side is to set the property back to false, and the obvious move from the detection side is to never trust the value in isolation. A property that should be a primitive boolean but turns out to be a getter on the instance rather than the prototype, or that has been redefined with Object.defineProperty, leaves traces. The descriptor changes, the property may become enumerable when it was not, and the function backing a faked getter has a toString that does not read like native code. None of that is the value of navigator.webdriver itself. It is the metadata around how the value got there, and that metadata is harder to launder than the boolean.

navigator.webdriver false true --enable-automation --headless --marionette (Firefox) a human browser never reaches the lower state; resetting the boolean leaves descriptor residue *The launch flags that set the boolean. The value is trivial to overwrite; the way it was overwritten is not.*

The deeper detail is covered in the navigator.permissions query inconsistency post and the broader headless Chrome detection reference, because webdriver rarely fires alone. It is the first thing a detector reads and almost never the last.

The identity block: userAgent and its supporting cast

After webdriver, the navigator object’s job is to describe the browser. It does this through a cluster of string properties that all have to tell the same story, and the historical accident that holds them together is that most of them were frozen for compatibility decades ago. They lie about the browser’s lineage in a fixed, predictable way, and that predictability is what a detector exploits.

navigator.userAgent is the headline. It carries the browser engine tokens, the OS string, and the version, and it is the value most automation tooling bothers to spoof. The catch is that the user agent is not the only place this information lives. The same facts are duplicated, in different shapes, across half a dozen sibling properties, and a spoof that rewrites one string while leaving the siblings at their defaults produces a browser that disagrees with itself.

navigator.platform is the first place that breaks. It returns a short OS token: Win32, MacIntel, or Linux x86_64. On 64-bit Windows it still returns Win32, a quirk that catches naive spoofs that “correct” it to Win64. MDN now flags the property as unreliable and recommends against using it for browser detection, pointing at the User-Agent reduction effort and the general advice to prefer feature detection. That deprecation note matters less to a detector than to a feature author. A bot-detection script does not care whether platform is the right way to learn the OS. It cares whether platform agrees with the OS token inside userAgent. A user agent that says Macintosh; Intel Mac OS X with a platform of Win32 is not a Mac and not a real configuration.

navigator.vendor is the cleanest single discriminator between engines. On Chrome and other Chromium browsers it returns the literal string Google Inc.. On Safari it returns Apple Computer, Inc.. On Firefox it returns the empty string. A user agent claiming Chrome with an empty vendor, or claiming Firefox with vendor set to Google Inc., is internally inconsistent in a way no shipping browser produces. This is the field that catches the laziest spoofs, the ones that rewrite the user agent and stop there.

navigator.product is a fossil. Every modern browser returns the hard-coded string Gecko regardless of engine, a compatibility lie that dates to the era when sites sniffed for Gecko to decide which code path to run. Because it is a constant, it carries no entropy, but a browser that returns anything other than Gecko here has been tampered with.

navigator.productSub is the sharp one. It is supposed to be a build identifier, and in practice it is another frozen constant that differs by engine. On Chrome and Safari it returns 20030107. On Firefox it returns 20100101. That single number is a reliable engine tell, and it is the basis for one of the oldest consistency checks in the open-source fingerprinting libraries. FingerprintJS2 shipped a getHasLiedBrowser method whose logic is exactly this: a browser is lying about its user agent if it claims to be Chrome with a productSub other than 20030107, or claims to be Firefox with a productSub equal to 20030107. The check is cheap, it predates most anti-detect tooling, and it still catches spoofs that forget the field exists.

navigator.appVersion duplicates most of the user agent minus the leading Mozilla/ token, and navigator.appName is another fossil that returns Netscape everywhere. Both have to stay consistent with userAgent because they are derived from the same underlying string in a real browser. Rewrite userAgent through Object.defineProperty and forget appVersion, and the two no longer match.

The Firefox-only property in this block is navigator.oscpu. Gecko exposes an OS-and-architecture string here, something like Windows NT 10.0; Win64; x64. Chromium and WebKit do not implement the property at all, so reading it returns undefined. That asymmetry is itself a check. A navigator that claims to be Firefox through its user agent but returns undefined for oscpu is not Firefox; a navigator that claims to be Chrome but exposes a populated oscpu string has had Firefox-shaped spoofing applied to a Chromium base. The LinkedIn fingerprinting work that Castle documented uses exactly this surface in its getHasLiedOs routine, parsing the claimed OS out of userAgent and checking it against oscpu and platform for agreement.

property Chrome Firefox Safari vendor Google Inc. "" (empty) Apple… productSub 20030107 20100101 20030107 product Gecko Gecko Gecko oscpu undefined Windows NT… undefined platform Win32 / MacIntel Win32 / Linux… MacIntel appName Netscape Netscape Netscape the orange cells are the cheapest engine discriminators; all six rows must agree with userAgent *Frozen constants and engine-specific quirks. The values carry little entropy on their own; their job is to corroborate the user agent.*

The reason this block is so dangerous to a spoof is that the legitimate values are not diverse. They are the same on every Chrome install on earth. So they add almost nothing to the entropy of a real fingerprint, which means a detector loses nothing by treating them purely as a consistency net. There is no false-positive cost to checking that Chrome’s vendor reads Google Inc., because every real Chrome’s does. For the full historical arc of the user agent string itself, the userAgent post traces how the HeadlessChrome token and User-Agent reduction reshaped this surface.

The hardware tells: hardwareConcurrency and deviceMemory

Two navigator properties report hardware, and both are quantized in ways a careless spoof gets wrong. They also matter more than the string block, because unlike vendor or productSub they actually vary across real machines and therefore carry genuine entropy.

navigator.hardwareConcurrency returns the number of logical processors available to run threads. It is defined in the WHATWG HTML Living Standard, in the section on workers, and on Chromium it returns the true logical core count, hyperthreads included. That honesty is the entropy source: an 8-core, 16-thread desktop reports 16, a 4-core laptop reports 8, and the distribution across real users is wide enough to narrow an anonymity set. Firefox and Safari both clamp it. With privacy.resistFingerprinting enabled, Firefox returns a fixed 2. WebKit caps the reported value at 8 on macOS and 2 on iOS to limit hardware fingerprinting on Apple devices. Firefox also carried a general cap of 16 for a stretch; Mozilla’s bug tracker records Tom Ritter defending it on the grounds that a higher maximum would make a minority of users’ fingerprints more unique, and the cap was raised in Firefox 97 only after real-world breakage, a chess analysis site running at half speed because it could not see the cores it needed.

The spoofing failure mode here is subtle. A faked hardwareConcurrency is one number, and setting it to a plausible value like 8 is easy. The problem is that the core count is observable indirectly. A script can spin up workers and measure how many run truly in parallel, and a machine that claims 16 cores but cannot execute 16 worker threads concurrently has been caught. The niespodd fingerprinting catalog references exactly this “core-estimator” technique, comparing the declared hardwareConcurrency against the parallelism the browser actually delivers. The declared number and the observed number have to match, and only one of them is under the spoof’s control.

navigator.deviceMemory is the second hardware tell, and it is the more rule-bound of the two. It reports an approximate amount of system RAM in gigabytes, and the W3C Device Memory specification, a Working Draft, is precise about how the value is produced. The algorithm takes the physical memory in bytes, converts to gibibytes, rounds to a single significant bit, meaning the nearest power of two, and then clamps the result between an implementation-defined lower and upper bound. The point of all three steps is anti-fingerprinting: rounding to one significant bit collapses every machine in a wide RAM band onto the same bucket, and the clamp hides the owners of unusually small or unusually large machines.

In practice Chrome’s legitimate output is a short list. Historically the property returned one of 0.25, 0.5, 1, 2, 4, or 8, with 8 as a hard ceiling enforced in the Chromium source by a clamp that sets anything above 8 back to 8. Newer Chrome versions can report higher buckets on non-Android platforms, 16 and 32, but the values stay on the power-of-two grid. That grid is the trap. A bucketed value like 3 or 6 cannot come out of the real algorithm, so a spoof that sets deviceMemory to a “realistic” 6 GB has produced a number no shipping Chrome would ever report. Castle’s traffic analysis found exactly this in the wild: values above 8 in tens of thousands of events, off-grid numbers, the property present in non-Chromium browsers that never implemented it, and the property missing entirely from sessions that claimed to be Chrome. Each of those is a clean rule violation, not a probabilistic judgment.

navigator.deviceMemory — the power-of-two grid 0.5 1 2 4 8 16 32 3 ✗ 6 ✗ only the white points are reachable; any value between them is a value no real algorithm produces *Rounding to a single significant bit means the only legal outputs sit on powers of two. Off-grid numbers are an unforced spoofing error.*

Both hardware properties also exist on the worker side, through WorkerNavigator, which means a detector can read them from inside a Web Worker as well as the main thread. A spoof that patches navigator on the main document but leaves the worker’s navigator untouched produces two different answers for the same machine. That worker-context divergence is its own detection family, covered in the iframe and worker context fingerprinting post, and it applies to every navigator property, not just the hardware ones. The deeper treatment of these two specific signals lives in the hardware concurrency and device memory reference.

navigator.languages returns an array of the user’s preferred locales in priority order, with navigator.language holding the first entry. A typical desktop value is ["en-US", "en"]. This is entropy, because the full ordered list of a user’s language preferences is more identifying than any single locale, and it is a signal a script cannot fully fake in isolation, because the same preferences leak through a channel the script does not control.

That channel is the Accept-Language HTTP header, sent on every request the browser makes. The header generally lists the same locales as navigator.languages, with decreasing quality values, and Chrome and Safari add language-only fallback tags. A navigator.languages of ["en-US", "zh-CN"] produces an Accept-Language of roughly en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7. The server sees the header before any JavaScript runs. The detection script reports the array. If the two disagree, the browser is announcing one set of preferences over the wire and a different set in the DOM, which no untampered browser does. A headless client that sets Accept-Language at the HTTP layer but forgets to align navigator.languages, or the reverse, fails this cross-check.

Browser privacy work has been narrowing this surface from the legitimate side. Safari has long sent only a single language, and Chrome’s Accept-Language Reduction effort, prototyped through Chromium’s blink-dev process, moves toward sending only the user’s most preferred language in the header to cut passive fingerprinting. That reduction changes what “consistent” looks like over time, but it does not remove the cross-check. It just means the expected relationship between the header and the array is itself version-dependent, and a detector that knows the current Chrome behavior can flag a client whose header-to-array mapping matches no shipping version.

The other relationships that bind navigator.languages reach outside the navigator object entirely. The language preferences should be plausible against the timezone the browser reports and against the geolocation of the IP address. A session whose navigator.languages is ["ru-RU"], whose timezone is America/New_York, and whose IP geolocates to a data center in Germany is not impossible, but it is three signals pointing three directions, and a scoring model adds that up. The timezone side of that triangle is the subject of the timezone, locale, and Intl API post.

userAgentData: the structured replacement

The classic userAgent string is being deliberately frozen and shrunk through User-Agent reduction, and the structured replacement is navigator.userAgentData, an instance of NavigatorUAData that exposes browser identity through the User-Agent Client Hints API. It is a Chromium feature. Firefox and Safari do not implement it, so on those browsers navigator.userAgentData is undefined, and that absence is itself a tell: a navigator claiming Chrome through its user agent string but returning undefined for userAgentData is either a very old Chrome or not Chrome at all.

The object splits its data by entropy. Three low-entropy properties are always available without a promise: brands, an array of brand-and-version objects identifying the browser; mobile, a boolean for whether the user agent runs on a mobile device; and platform, the platform brand string such as "Windows". Anything more identifying sits behind getHighEntropyValues(), an async method that takes a list of hint names and resolves with the requested values. The accepted high-entropy hints are architecture, bitness, formFactors, fullVersionList, model, platformVersion, uaFullVersion, and wow64. The async boundary is the privacy control: it gives the browser a place to gate or prompt before handing over the more revealing fields.

For a detector, userAgentData is less a new signal than a third copy of the identity story that has to match the other two. The same facts now exist in three places: the legacy userAgent string, the JavaScript userAgentData object, and the Sec-CH-UA family of request headers (Sec-CH-UA, Sec-CH-UA-Platform, Sec-CH-UA-Mobile) that the browser sends over the wire. All three should describe one browser on one OS. The mismatch patterns are concrete. A mobile-looking user agent paired with Sec-CH-UA-Mobile: ?0. A Safari-shaped user agent string while Sec-CH-UA still lists Chromium, which is an implausible combination for real traffic. A user agent claiming iPhone while Sec-CH-UA-Platform reports Windows. Detection here is partly rule-based, blocking impossible combinations outright, and partly scored, accumulating smaller mismatches across language and timezone and rendering until the total crosses a threshold. The relationships between the values matter more than any single value being correct.

one browser, three copies of the same story navigator .userAgent legacy string navigator .userAgentData JS object Sec-CH-UA-* request headers consistency engine spoof one surface and the other two contradict it; the engine scores the disagreement *The same OS, device type, and brand have to read the same across all three surfaces. A spoof usually patches one.*

The consistency engine

The individual properties are the easy part. What turns the navigator object into a fingerprint that resists spoofing is the web of relationships between the properties, because a working spoof has to satisfy all of them at once and a real browser satisfies them for free. This is the single idea that runs through every serious detector: once a client starts lying about one thing, it tends to forget to lie consistently about everything else, and the consistency checks are built to find that gap.

The checks fall into a few families. Engine agreement is the simplest: vendor, productSub, and the presence or absence of oscpu and userAgentData all have to name the same engine the user agent claims. Chrome with an empty vendor is impossible. Firefox with productSub of 20030107 is impossible. OS agreement is the next layer: the OS token inside userAgent, the platform string, the Firefox oscpu, and Sec-CH-UA-Platform all have to describe the same operating system, which is the heart of the getHasLiedOs style of check. Then there is form-factor agreement, where Sec-CH-UA-Mobile, the presence of touch support through maxTouchPoints, the screen dimensions, and the mobile or desktop shape of the user agent all have to point at the same kind of device. A desktop user agent with a touch-enabled mobile profile is a contradiction.

The hardware checks add a quantization layer on top. deviceMemory has to sit on the power-of-two grid and within the clamp, hardwareConcurrency has to match the parallelism a worker probe actually observes, and both have to read the same value from the worker context as from the main thread. The language checks bind navigator.languages to the Accept-Language header, to the timezone, and loosely to IP geolocation. And underneath all of it sits a meta-check that does not look at any property value at all: it looks at how the properties were defined. A native navigator property is a getter on Navigator.prototype whose backing function reports native code from its toString. A spoofed property is frequently an own-property on the instance, or a getter whose function body is visible JavaScript, or a value whose property descriptor has the wrong enumerability. The JavaScript runtime fingerprinting post covers that descriptor-and-prototype surface, which is where most spoofs ultimately leak even when every value is correct.

The exact weighting each commercial vendor applies to these checks is not public. What is public is the shape: a mix of hard rules that block impossible combinations outright and a scoring model that sums smaller inconsistencies. The Castle and Wilico write-ups both describe the same two-tier structure, and the open-source libraries like the old FingerprintJS2 getHasLied* methods show the rule layer in source form. The internal thresholds, the per-signal weights, and the precise feature vectors that vendors like DataDome and Akamai feed into their models are not documented, and anyone claiming exact numbers there is guessing. What follows from the public material is the direction: more cross-checks, tighter coupling between the JavaScript layer and the HTTP layer, and a steady move from trusting values to auditing how values were produced.

What the object actually proves

The navigator object reads like a description of a machine, and a detector treats it as nothing of the sort. It treats it as a set of claims to be checked against each other and against the wire. The properties that carry real entropy, hardwareConcurrency, deviceMemory, the full ordered languages array, are quantized or clamped specifically to reduce that entropy, which has the side effect of making off-grid spoofed values stand out. The properties that carry almost no entropy, vendor, product, productSub, appName, exist now mostly as a consistency net, frozen constants that cost a real browser nothing and cost a spoof a constant tax to reproduce correctly. And webdriver sits over all of it as the one field that was put there on purpose, for the page to read, by a standards body that decided automation should be declarable.

The practical consequence is that the navigator object is not where a modern spoof wins or loses on its own. A determined anti-detect setup can make every value correct, and the better ones do. Where it loses is at the seams: the worker context that reports a different answer than the main thread, the Accept-Language header that the HTTP client set independently of the DOM, the property descriptor that betrays a getter, the core count that a worker probe contradicts. The object’s individual fields are a solved problem for a spoof. The relationships between them, and between them and everything outside the object, are not, and that asymmetry is the whole reason navigator is still worth reading on the first request.


Sources & further reading

Further reading