Skip to content
Back to the fingerprint testReference · 154 checks

Browser fingerprint checks: complete reference

A subsystem-by-subsystem reference to every check in the Clearcote browser fingerprint test. Each entry explains the expected value, what a mismatch reveals, and how to resolve it.

What this audit checks, and why each signal is legible

Almost every check below is self-referential: it asks this browser the same question through two independent APIs and reports whether both answers can be true at once. That is why it needs no baseline corpus — a contradiction is visible from inside the page. The WebRTC address check is the lone exception, and the only one that makes a network call: comparing where your media exits against where your page arrived from is not answerable from inside. Each entry states what a detector infers when the check fails, what an ordinary unmodified browser yields, and what to change.

HTTP request vs the runtime

accept-language-vs-navigatorThe first language in Accept-Language equals navigator.languages[0]. The header carrying fewer languages than JS reports is normal and not asserted.

What a detector infers

Accept-Language and navigator.languages are two views of one browser preference: the header is serialised by the network stack, the array is read by JS. They must lead with the same language. What the check deliberately does NOT do is compare the full list, and the reason is a measurement rather than a guess. Current Chrome ships Accept-Language reduction — it sends a single language on the wire while still reporting the complete list to JS. Measured on a stock browser during development: the header carried exactly `en-GB` while navigator.languages held ['en-GB', 'en-NL']. A full-list comparison would therefore fail an ordinary, unmodified Chrome, which is the failure mode this audit takes most seriously. Only the primary tag is a fact both layers still agree on, so only the primary tag is asserted. Severity is warn rather than critical because the header's exact serialisation is a moving target — the reduction itself is a recent change, and the surface may narrow further.

How to resolve it

Set the language in the browser rather than overriding navigator.languages: the header is written from the real preference, so a JS-side edit leaves the wire advertising the language you were trying to change.

Anatomy of a browser fingerprint: every signal, and why they must agree
sec-ch-ua-vs-uadatasec-ch-ua's brand set, sec-ch-ua-mobile and sec-ch-ua-platform match navigator.userAgentData's brands, mobile and platform exactly.

What a detector infers

User-Agent Client Hints exist in two places at once, which is what makes them checkable. The browser serialises its brand list into the sec-ch-ua, sec-ch-ua-mobile and sec-ch-ua-platform request headers, and exposes the same list to script as navigator.userAgentData. One internal source, two renderings — so they must agree, and patching userAgentData in the page world leaves the headers announcing the truth. These three are also the only hints sent cross-origin without explicit delegation, which is precisely why they are the three compared. Two details keep the check honest. Brands are compared as a SET rather than a sequence, because the specification deliberately shuffles brand order (and injects a GREASE brand like 'Not/A)Brand') to stop anyone depending on the arrangement — asserting order would fail real browsers by design. And the whole check declines on engines that have no client hints at all, since Gecko and WebKit ship neither the headers nor the API, and their absence is a fact about the engine rather than a contradiction.

How to resolve it

The hints are written onto the wire from the same brand list the API reads. Overriding navigator.userAgentData in the page leaves the headers unchanged, so the browser ends up announcing two identities on one request. If the brand list must differ, it has to be changed below JavaScript.

What are User-Agent Client Hints (UA-CH)?
sec-fetch-metadataSec-Fetch-Mode: cors and Sec-Fetch-Dest: empty, with Sec-Fetch-Site either same-site or cross-site depending on the probe's domain.

What a detector infers

Sec-Fetch-Site, Sec-Fetch-Mode and Sec-Fetch-Dest are attached by the browser to describe the request's own provenance — where it came from, what kind of fetch it was, and what the result is for. They are explicitly forbidden to page script: fetch() cannot set them, which is the entire design of the header family. So for a request this page just made with fetch(), their values are specified in advance, and the check compares what arrived against what the spec requires: mode=cors, dest=empty. The site value is accepted as either same-site or cross-site, because the probe shares a registrable domain with this page and legitimately reports same-site — a detail worth stating, since asserting cross-site would have been wrong here and was caught only by measuring. Absence is treated as no measurement rather than a finding: these headers ship in every current browser, but an old one lacking them is old, not automated, and this audit does not convict browsers for their age.

How to resolve it

Nothing to fix on an ordinary browser. These headers cannot be set from script by design, so values disagreeing with the request the page actually made mean the request was replayed or synthesised outside the browser's own fetch — which is the situation the header family exists to make visible.

How automation gets caught, layer by layer
http-header-order-readoutContextual only. There is no correct answer to compare against — the order is shown so you can see what a server learns from a request before it reads a single value.

What a detector infers

A browser emits its request headers in a fixed sequence compiled into its network stack, and that sequence differs between engines — Chromium, Gecko and WebKit each have their own, and an HTTP library has one that resembles none of them. It is genuinely a signal, and it is one JavaScript cannot see or change: by the time page script runs, the order was decided by code far below it. This row is nevertheless REPORTED and never scored, and the reason is a rule this audit learned the hard way. Naming a browser from its header order requires a table of known orders, and tables rot: this page already shipped one — a set of V8 heap-size values — which went stale and began telling people with a stock Chrome that their browser was implausible. A header-order table would rot faster and fail louder. Every scored check here compares the browser against itself, so it can only ever go quiet when it meets something unfamiliar; a lookup table goes wrong instead. So the order is shown, with an explanation of what it reveals, and no verdict is attached to it.

How to resolve it

Nothing to fix. If you are presenting an identity that the header order contradicts, note that the order comes from the network stack rather than from anything a page or an extension sets — matching it means using the engine you claim, not editing a value.

How automation gets caught, layer by layer
request-cache-headersNothing. A default-mode fetch and a fresh navigation both arrive with no Cache-Control, no Pragma and no conditional headers.

What a detector infers

A real Chrome sends no cache-validation headers on a request it has not been told to revalidate. This was measured rather than assumed: against our own probe, a plain fetch carried no Cache-Control and no Pragma, while fetch with cache:"no-store" and cache:"reload" both emitted Pragma: no-cache and Cache-Control: no-cache. The absence is the browser's genuine behaviour on a cold navigation, so any presence was authored by something. Automation stacks author it routinely. A navigation path forced to bypass the cache stamps no-cache and Pragma onto every top-level navigation; a reload stamps max-age=0; a driver rewriting headers through a request interceptor stamps whatever it intercepts. What makes this worth reading is where it lives: the header is written by the network stack, below the runtime, before page script exists. No override on navigator reaches it, nothing in JavaScript reveals it, and a page cannot even read its own request headers to notice — which is why it survives every check that only reads the JS surface, and why a server has to be asked. The check uses two arms because they catch different mistakes: a plain fetch catches a driver stamping everything it touches, and an iframe load — a genuine navigation, Sec-Fetch-Mode: navigate, where a fetch is not — catches a stack that stamps navigations specifically. The iframe is created late, on purpose: Chrome propagates a reload's cache mode to the subresources of that load, so a frame created during load could inherit a hard reload's no-cache and make an honest visitor look driven.

How to resolve it

These headers are attached by the network stack rather than by page script, so nothing in JavaScript put them there and nothing in JavaScript will take them off. They appear when a navigation is forced to bypass the cache, or when a driver rewrites headers through a request interceptor. Fix it where it is written: stop forcing cache-bypass on navigations, and stop adding headers a real cold navigation does not carry. The honest alternative reading is why this row never scores — a corporate TLS-terminating proxy or a CDN in front of a site can add request headers too, and that is the network's doing rather than the browser's.

How automation gets caught, layer by layer

Screen & display

screen-matchmediamatchMedia(device-width/device-height) matches the reported screen.width × screen.height exactly.

What a detector infers

This check reads window.screen.width/height, then asks the CSS media-query engine the same question via matchMedia("(device-width: Npx) and (device-height: Npx)"). The two values come from different layers: screen.* is a navigator-adjacent JS property that is trivial to override, while the device-width/device-height media features are resolved by the layout engine against the actual render surface it was handed. A detector that reads both and finds them disagreeing learns that the JS-visible screen size was rewritten after the fact while the compositor kept rendering at the real size. The inference is reliable because there is no ordinary configuration — no zoom level, no multi-monitor setup, no DPI scaling — in which a real browser's screen dimensions and its own device-width media feature diverge; they are derived from the same display metrics.

How to resolve it

Do not override screen dimensions in JS. If a specific screen size is required, give the browser a real render surface of that size — run headful on a display with those dimensions, or use a virtual display (Xvfb and similar) sized accordingly — so the JS property and the layout engine derive from the same source.

What is screen fingerprinting?
dpr-matchmediamatchMedia resolution in dppx falls within ±0.01 of the reported devicePixelRatio (or the feature is unsupported, which reads N/A).

What a detector infers

The check compares window.devicePixelRatio against the layout engine's own answer, querying matchMedia with a min-resolution/max-resolution band in dppx around the reported value. It first feature-detects support for the resolution media feature and reports N/A where the feature is unavailable, and it uses a ±0.01 tolerance band so that legitimate fractional ratios (Windows display scaling, Android densities) are not failed by floating-point round-tripping. Like the screen check, this is a two-source read of one underlying quantity: devicePixelRatio is a JS property, while resolution in dppx is computed by the rendering pipeline. A detector observing a mismatch infers that the ratio was patched at the JS surface while rasterization continued at the true scale — an inconsistency a genuine browser cannot produce, since both trace back to the same device scale factor.

How to resolve it

Leave devicePixelRatio to the real rendering scale. To present a different ratio, set it at launch through the browser's device-scale-factor so the compositor rasterizes at that scale, rather than overriding the property from page script.

What is screen fingerprinting?
screen-plausiblescreen.width and screen.height each land between 200 and 16384 px — a size a real panel or virtual desktop could have.

What a detector infers

A pure sanity range on screen.width and screen.height: both must be at least 200px and no more than 16384px. This is not a coherence cross-check but a plausibility floor and ceiling. Its practical value is at the low end — a screen of 0×0 or a handful of pixels is what an environment with no attached display reports, so the value itself describes the environment rather than a display. The upper bound catches values beyond any shipping panel or reasonable multi-monitor virtual desktop. A detector treats out-of-range geometry as evidence that nothing is actually being presented to a screen, or that the field was written without regard to what real hardware reports. Severity is warn: it flags an implausible value, not a proven contradiction between two signals.

How to resolve it

Ensure the browser has a real render surface. In a headless or containerized environment, attach a virtual display of a normal desktop resolution so screen.* reports genuine geometry instead of zeros or placeholder values.

How is a headless browser detected?
taskbar-presentavail dimensions are smaller than the full screen on at least one axis, reflecting a reserved taskbar or dock — though equality is legitimate in several ordinary setups.

What a detector infers

screen.availWidth/availHeight describe the area left over after the OS reserves space for its own chrome — a Windows taskbar, a macOS dock and menu bar. The check computes the width inset and, if that is zero, falls back to the height inset, and reports whether any inset exists at all. On a typical desktop session, at least one edge is reserved, so avail is strictly smaller than screen on that axis. When the two are identical, a detector learns the environment has no OS shell reserving screen space, which is common in headless and off-screen rendering. The inference is weak on its own, which is why this check is info severity and not scored: fullscreen sessions, auto-hiding taskbars, and some Linux window managers legitimately produce screen === avail. It is context to read alongside the other geometry checks, not a verdict.

How to resolve it

Not directly fixable, and not scored. If the surrounding geometry signals are coherent, screen === avail is unremarkable. Running headful against a real desktop session naturally produces an inset; a bare virtual display will not, and that is expected rather than wrong.

What is screen fingerprinting?
window-boundsouterWidth and outerHeight are non-zero and fit within the reported screen (outer ≤ availWidth and ≤ screen height, ±1px).

What a detector infers

The check asserts only the zoom-invariant bound: window.outerWidth/outerHeight must be non-zero and must fit within the screen (outer width within availWidth, outer height within screen height, each with a 1px slack). It deliberately does not assert inner ≤ outer, because zooming a page out legitimately grows innerWidth past outerWidth in an ordinary Chrome. Two distinct signals emerge. Zero outer dimensions mean no window is being presented — a visible window always reports real outer geometry. A non-zero window that is larger than the display it claims to sit on is the classic residue of a spoofed screen: the screen fields were shrunk to some persona's resolution while the actual window kept its real, larger size. A detector infers from either that the reported screen is not the surface the window lives on. Severity is warn.

How to resolve it

Size the window to fit the screen it claims. If a screen size is being set, pin the window to it at launch (an explicit --window-size within those bounds) so outerWidth/outerHeight, screenX/screenY and the screen fields all describe the same surface. Zero outer dimensions mean the browser has no visible window — give it a real or virtual display.

What is screen fingerprinting?
pointer-hover-desktopOn a desktop UA with maxTouchPoints=0, both (any-pointer: fine) and (hover: hover) match; either one failing to match fails the check.

What a detector infers

This check applies only when the UA identifies a desktop platform (Windows, Macintosh, Mac OS X, X11, Linux, CrOS, and not Android/Mobile) and navigator.maxTouchPoints is 0; anything else reads N/A. In that case it queries matchMedia for (any-pointer: fine) and (hover: hover), and fails if either one does not match. The two features say slightly different things: (any-pointer: fine) asks whether any available input mechanism is a fine pointer — a mouse, trackpad or stylus — while (hover: hover) asks whether the primary input mechanism can hover. Both are answered by the browser from the input devices the platform actually reports. A headless environment has no pointing device attached and so matches neither. A detector reading a desktop UA that claims no touch input yet exposes no fine pointer, or no hover, concludes that the platform string describes a machine that isn't there: the identity says desktop-with-a-mouse, the input layer says nothing is connected. Severity is warn.

How to resolve it

Run against an environment that has a real pointing device — headful on a desktop session, or a virtual display where the browser sees a pointer — so the input media features reflect the platform the UA claims. Do not patch the media-query results in JS; they are resolved by the engine and re-read from other realms.

How is a headless browser detected?
touch-pointer-coherencemaxTouchPoints > 0 is accompanied by a matching (any-pointer: coarse); maxTouchPoints = 0 imposes no requirement.

What a detector infers

A one-directional coherence test: if navigator.maxTouchPoints is greater than zero, matchMedia("(any-pointer: coarse)") is expected to match. It passes automatically when maxTouchPoints is 0, since a mouse-only machine legitimately has no coarse pointer. The two signals describe the same fact from different layers — maxTouchPoints is a navigator property, any-pointer: coarse is answered by the layout engine from the platform's input devices. Claiming touch support while the engine reports no coarse pointer tells a detector that the touch count was written onto the navigator object without a touch digitizer behind it, a signature of a persona applied only at the JS surface. This most often shows up when a mobile or touch-laptop identity is layered onto a desktop host. Severity is warn.

How to resolve it

Set touch support at the browser level rather than by overriding navigator.maxTouchPoints — enable touch emulation at launch so the engine registers a coarse pointer and both signals derive from one source. If touch emulation isn't available in the environment, leave maxTouchPoints at 0 rather than claiming touch the input layer cannot corroborate.

How is mobile emulation detected?
screen-descriptorswidth/height/avail*/colorDepth/pixelDepth are all native Screen.prototype getters, with no own descriptors on screen and no window-geometry properties present.

What a detector infers

For width, height, availWidth, availHeight, colorDepth and pixelDepth this asks two questions: does the screen instance carry an own descriptor (real browsers expose these only via Screen.prototype), and is the Screen.prototype entry still a native getter rather than a data property or a JS function. It also checks four tripwire names — innerWidth, innerHeight, outerWidth, outerHeight — which belong to Window and never to Screen; their presence on screen indicates a catch-all Proxy or a spoof mirroring window geometry onto the screen object. The reason this is severity 'critical' is that Object.defineProperty(screen,'width',{get:…}) is the most common patch there is, and it never changes a value's plausibility, so every value-coherence check still passes while the mechanism is plainly visible in the descriptor.

How to resolve it

Spoof the screen at the engine level rather than via Object.defineProperty on the screen instance, which leaves both an own descriptor and a JS getter behind. Note that this check inspects mechanism, not values: leaving the real screen values in place passes it outright, which is often the simplest option.

What is screen fingerprinting?
orientation-vs-screen-dimsorientation.type agrees with the screen's own width/height; portrait implies height > width.

What a detector infers

screen.orientation.type and screen.width/height are both device-space values sourced from the same display metrics, so they must agree: a type starting with "portrait" implies screen.height > screen.width. The check reads screen.orientation.type and compares that predicate against the reported dimensions. It deliberately stays in device space and does not consult the CSS (orientation:) media query, which is viewport-space — a half-screen snapped desktop window genuinely resolves portrait while the screen is landscape, so crossing that boundary would misfire on real browsers. Two degenerate cases are excluded from scoring rather than guessed at: a square screen (1080x1080 panels exist) and a zero-width or zero-height screen carry no orientation information. A mismatch is informative because it indicates an orientation value from one persona layered over host screen dimensions from another — typically a mobile persona applied to a landscape host. Severity is critical.

How to resolve it

Keep screen.orientation.type consistent with screen.width and screen.height. Both are device-space, so a physically rotated monitor still satisfies this — set them together as one coherent display description rather than overriding orientation independently.

What is screen fingerprinting?
touch-createevent-coherenceA mobile UA reports maxTouchPoints > 0 and document.createEvent('TouchEvent') succeeds.

What a detector infers

This check asserts in one direction only: if navigator.userAgent matches iPhone, iPad, iPod or Android, then the environment must also have navigator.maxTouchPoints > 0 and a constructible TouchEvent via document.createEvent('TouchEvent'). It does not assert the converse — touch present therefore UA must be desktop — because that would hard-fail real iPadOS desktop-mode Safari and every touchscreen laptop. The createEvent leg is the load-bearing one: it is gated by the engine's internal event-factory table, not by a navigator binding, so no amount of defineProperty on navigator reaches it. A mobile UA with maxTouchPoints=0 or a non-constructible TouchEvent therefore indicates that the user-agent string was changed without the touch stack behind it. Severity is warn; a desktop UA yields N/A.

How to resolve it

Enable real touch emulation at the engine level rather than only swapping the user-agent string. maxTouchPoints can be overridden from JS, but the event-factory table that governs createEvent('TouchEvent') cannot.

How is mobile emulation detected?
visual-viewport-vs-windowAt scale 1 the difference between innerWidth and visualViewport.width is between 0 and about 20 pixels — a scrollbar gutter and nothing else.

What a detector infers

window.innerWidth and visualViewport.width are two independent readings of one viewport, and they are related by exactly one thing: the classic scrollbar gutter, which innerWidth includes and the visual viewport excludes. That makes the GAP between them the signal, not either value. At scale 1 the difference can only be a scrollbar, because both numbers describe the same surface — one measured by the layout engine, the other reported by the compositor. The gutter is not hard-coded but MEASURED, with a private probe element, and that detail is the whole check. A scrollbar is a fixed physical widget sized from the device scale factor, which does not include page zoom, while innerWidth and visualViewport.width are both divided by the zoom factor — so the gutter's width in CSS pixels GROWS as you zoom out, roughly fifteen pixels at 100% but thirty at 50% and sixty at 25%. A fixed CSS-pixel bound therefore fails an honest Windows or Linux Chrome that is merely zoomed out, and the pinch-zoom guard never catches it because desktop page zoom leaves visualViewport.scale at exactly 1. The probe is also deliberately independent of innerWidth rather than derived from it: a rewritten innerWidth would shift both sides of a derived comparison by the same amount and cancel itself out. A viewport size rewritten in JavaScript moves one of the two and not the other, and the gap immediately stops being scrollbar-shaped. On a stock Chrome this was measured at innerWidth 1624 against visualViewport.width 1614: a ten-pixel overlay scrollbar, exactly as it should be. The check declines when the page is pinch-zoomed, because the visual viewport is the zoomed viewport by design and the two readings are then legitimately scaled apart rather than contradictory — that is the difference between a signal and a false positive. Kasada reads seven visual_viewport_* probes; this is the coherence that makes them worth reading.

How to resolve it

Do not rewrite innerWidth/innerHeight. The visual viewport is reported by the compositor and keeps describing the real surface, so patching the window properties only separates two numbers that must stay a scrollbar apart. Size the actual window instead — at launch, so both readings derive from the same surface.

How is a headless browser detected?
nav-timing-orderEvery non-zero milestone occurs at or after the one before it, and none is later than performance.now().

What a detector infers

Akamai Bot Manager v2 devotes an entire signal category to timing — navigation timing, resource timing, first paint, DOM-ready — and this audit had no coverage of it at all. What makes the surface worth reading is that its milestones are not free-floating numbers. The Navigation Timing specification defines them as a causal sequence: fetching starts before the domain is looked up, the request is sent before the response arrives, parsing finishes before load fires. So the check reads the PerformanceNavigationTiming entry and asserts only the order, which the browser wrote as the load actually happened. Fabricating convincing timings means fabricating an entire partial order rather than a handful of plausible durations, and an order is far harder to get right. Three details keep it honest. Zeros are dropped rather than compared, because a milestone that legitimately did not occur reports zero — redirectStart with no redirect, secureConnectionStart on plain http, workerStart with no service worker — and comparing those would fail every ordinary page. The assertion is non-decreasing rather than strictly increasing, because a reused or cached connection collapses the lookup and connect milestones onto requestStart, and equal timings are normal. And nothing may sit in the future: every milestone shares a clock with performance.now(), so a milestone beyond now() is not a slow page but a fabricated one. This is the rare reference the audit can afford, because the ordering comes from a specification rather than from a table of observed values — it can go quiet on a browser that exposes no entry, but it cannot go stale.

How to resolve it

Let the browser report its own load. Navigation timing is written as the page actually loads, so the milestones are a causal chain rather than a set of independent numbers — a response cannot precede its request. Rewriting the entries to look realistic means reproducing that whole order, including the zeros that correctly appear for a redirect that never happened or a connection that was reused.

How is a headless browser detected?

Navigator identity

platform-coherenceAll three surfaces resolve to the same OS family — e.g. a Windows UA alongside platform "Win32" and UA-CH platform "Windows".

What a detector infers

Three independent surfaces each name an operating system, and this check normalises all three to one family (win / ios / mac / lin) and requires them to match. It classifies the UA string by regex, navigator.platform by its own prefix (Win/Mac/Linux/arm), and navigator.userAgentData.platform by its label; when UA-CH is absent it is treated as agreeing rather than penalised. The mechanism a detector relies on is that these fields are populated from one source in a real build, so they cannot disagree by accident — a mismatch means at least one was written by hand. This is severity critical because the inference is essentially free and essentially certain: no configuration error inside a real browser produces a Windows UA with a MacIntel platform.

How to resolve it

Drive all three from one persona. Keep --fingerprint-platform coherent with the UA string and the UA-CH platform value rather than overriding any one of them individually.

Identifying the engine behind the User-Agent
uach-ua-versionThe Chrome major in the UA string equals the version on the Chrome/Chromium brand entry in navigator.userAgentData.brands.

What a detector infers

The check extracts the major from Chrome/(\d+) in the UA string, finds the Chrome or Chromium entry in navigator.userAgentData.brands, and requires the brand's version to equal that major. Both fields come from the same version constant in a real build, so a disagreement identifies a hand-edited field directly. It matters more than a cosmetic mismatch: the claimed Chrome major also predicts the TLS ClientHello shape and the HTTP/2 SETTINGS the binary actually emits, so lying about it in JS desynchronises the JS layer from network-layer evidence the page cannot rewrite — a detector cross-referencing them gets a contradiction with no innocent explanation. The check is N/A-safe: if the UA carries no Chrome token, or the brand list has no Chrome/Chromium entry, it passes rather than guessing.

How to resolve it

Do not spoof brand_version to a different major than the binary actually is. If you need a different reported version, run a build that genuinely is that version so the UA, UA-CH, and handshake agree. Presets such as light_stealth leave the brand version real for this reason.

What are User-Agent Client Hints (UA-CH)?
uach-highentropyfullVersionList's Chrome version matches the UA major, the hinted platform matches the UA's OS, and fullVersionList carries a real vendor brand (e.g. "Google Chrome") beside the GREASE entry.

What a detector infers

This awaits navigator.userAgentData.getHighEntropyValues and compares the richer hints against the UA string on three axes, collecting every disagreement. First, the Google Chrome / Chromium major inside fullVersionList must equal the UA's Chrome major. Second, the high-entropy platform must equal the OS the UA implies (with an allowance for the Chrome OS / Chromium OS naming variants). Third — a headless-provenance signal rather than a mismatch — the check takes fullVersionList's own brand names, drops the GREASE entries, and if the runtime looks Chromium (window.chrome is present) and exactly one real brand survives and that brand is "Chromium", the build is flagged: real Chrome carries "Google Chrome" and Edge/Opera/Brave/Vivaldi carry a vendor brand, so a bare Chromium brand set is the Chrome-for-Testing / headless signature. Note that all three axes read fullVersionList — the low-entropy navigator.userAgentData.brands array is the input to the sibling uach-ua-version check, not to this one. If getHighEntropyValues is unavailable the check returns N/A instead of failing. Severity is warn: the low-entropy hints are the harder evidence, and these are corroborating.

How to resolve it

Populate fullVersionList and platform from the same persona that produces the UA string — those are the two axes scored here (architecture and bitness are reported for context but not tested). If fullVersionList carries only a bare Chromium brand, you are running a Chrome-for-Testing or plain-Chromium build — use a build that genuinely carries the Google Chrome brand rather than editing the brand entry to claim one.

What are User-Agent Client Hints (UA-CH)?
languages-coherencenavigator.language is character-for-character the first entry of navigator.languages (e.g. "en-US" with ["en-US", "en"]).

What a detector infers

In a real browser navigator.language is defined as the first element of navigator.languages — one preference list, two views onto it. This check simply tests navigator.language === navigator.languages[0]. Because the engine derives one from the other, they cannot drift apart on their own; when they do, a detector infers that the two properties were assigned separately, which points at a userland override that patched one accessor and missed the other. That inference generalises: partial spoofs of paired properties are among the cheapest tells available, since the detector needs no baseline of what a normal user looks like, only the invariant. Severity is warn, and the reported detail lists both values so you can see which side drifted.

How to resolve it

Set the locale in one place so both properties are derived from it, rather than assigning language and languages independently. While you are there, keep the list consistent with the Accept-Language header and the timezone/IP region.

Coherence over camouflage: why a plausible identity beats a hidden one
etslA Chrome UA with eval.toString().length === 33, or a non-Chrome UA (check reports N/A).

What a detector infers

The string a JS engine returns for a native function's source is an implementation detail, and its length differs per engine: eval.toString().length is 33 on V8 and 37 on Gecko and WebKit. The check reads that length and — this is the important design choice — gates on the CLAIMED UA rather than on window.chrome or any other engine probe. If the UA carries a Chrome/ token, V8's 33 is required; if it does not, the check returns N/A and is not scored. That framing is what gives it force: a non-V8 engine wearing a Chrome UA fails here even if it has scrubbed every obvious Chrome marker, because the number is produced by the engine's own stringifier deep below the property surface a spoof normally reaches. Severity is critical — the signal is a one-integer read with no plausible false-positive on a genuine Chrome.

How to resolve it

Present a UA that matches the engine you are actually running. If you need a Chrome identity, run a Blink/V8 build — this value is not something to patch at the JS layer, since the same divergence resurfaces across every other native's stringification.

Why does Function.toString() reveal a hooked function?
product-subA Chrome UA with productSub === "20030107", or a non-Chrome UA (check reports N/A).

What a detector infers

navigator.productSub is a frozen legacy constant, not a live value: Chrome, Safari and Opera return "20030107" while Firefox returns "20100101". The check reads it and, only when the UA carries a Chrome/ token, requires "20030107"; on a non-Chrome UA it returns N/A and is not scored. Like the eval-length probe, it is gated on the claimed identity rather than on an engine sniff, so it catches a Gecko engine behind a Chrome UA without depending on window.chrome existing. A detector reading it is asking the engine to identify itself through a channel most spoof layers never enumerate, because the property looks inert. Severity is warn rather than critical — it is a corroborating engine-family signal that lands alongside stronger ones.

How to resolve it

Match the UA to the real engine family. If a Chrome UA is reporting Firefox's 20100101, the underlying engine is Gecko and the fix is the engine choice, not the property — patching this one constant leaves every sibling engine tell intact.

How automation gets caught, layer by layer
device-memory-boundsdeviceMemory is absent, or is exactly one of 0.25, 0.5, 1, 2, 4, 8, 16, or 32. On a real Chrome the reading is 8 or below (4 and 8 are the ordinary values), since the spec clamps it at 8.

What a detector infers

navigator.deviceMemory does not report actual RAM — the spec requires the UA to quantise it to a coarse power-of-two rung before exposing it, which is what makes it a low-entropy hint rather than a hardware readout. This check reads the property and tests membership in {0.25, 0.5, 1, 2, 4, 8, 16, 32}. Any other number (6, 12, 3, 64) is a value no shipping browser's quantiser can produce, so a detector seeing it infers the field was written by something other than the engine — the value itself is the evidence, independent of whether the number is otherwise plausible. The accepted set is deliberately wider than what stock Chrome actually emits: the spec clamps the exposed value to an upper bound of 8, so a real Chrome never reports above 8 no matter how much RAM is installed. The check still tolerates 16 and 32 rather than hard-failing on the ceiling, because the quantisation — not the cap — is the part of the signal it scores. If the property is not exposed at all, the check passes (undefined is a legitimate state). Severity is warn.

How to resolve it

Set deviceMemory to a power-of-two rung in 0.25–32 (4 and 8 are the common real-world values, and stock Chrome never exposes more than 8). If your config takes an arbitrary integer, round it to the nearest rung rather than passing raw RAM through.

Spec invariants as lie detectors
hardware-concurrencyAn integer between 1 and 128 — typically 4, 8, 12, or 16 on consumer hardware.

What a detector infers

This is an info-severity observation, not a scored failure — it reports navigator.hardwareConcurrency and only notes whether the value is an integer in the range 1–128. The bounds are loose on purpose: real machines span that whole range, so the check cannot tell you a value is wrong, only that it is outside anything a physical CPU would present. Its research value is as context for other signals rather than as a verdict of its own: a detector rarely scores the core count in isolation, but does correlate it with deviceMemory, the claimed platform, and the render path (a persona claiming a 32-core workstation while reporting 2GB of memory and a software rasteriser is incoherent as a set, even though each field is individually legal).

How to resolve it

Nothing to fix if the value is a normal core count; this check is contextual and not scored. If it does read out of range, set hardwareConcurrency to an integer matching the machine class your persona describes, and keep it consistent with deviceMemory.

Spec invariants as lie detectors
keyboard-api-vs-platformnavigator.keyboard is present on desktop Chromium and absent on mobile Chromium — matching the form factor the user agent claims.

What a detector infers

The Keyboard API — navigator.keyboard, with getLayoutMap and lock — ships in desktop Chromium and nowhere else: not in Gecko, not in WebKit, and not in Chromium's own mobile builds. Its presence is therefore a statement about the engine and the form factor simultaneously, made by the binary rather than by the user agent string. The check compares that statement against what the UA claims: a mobile Chrome UA on a build that has the API is a desktop browser wearing a phone's name. Two guards keep it honest, and both exist because real browsers disagreed with the first draft. It tests PRESENCE rather than truthiness, because Brave neutralises the API as a fingerprinting defence by making the getter return null while leaving the property on Navigator.prototype — the truthiness form failed every stock Brave desktop install. And it declines entirely when a desktop UA appears on touch-primary hardware, because Chrome for Android's Request-desktop-site — a one-tap setting, and the default on large-screen tablets — rewrites navigator.userAgent to the Linux desktop string itself. A string the browser vendor rewrites on the user's behalf is not a form-factor claim worth adjudicating. The direction that matters, a desktop build behind a mobile persona, is still fully asserted, and that one the UA cannot fake away. Note carefully what is NOT asserted. getLayoutMap() resolves the OS's physical keyboard layout, which would corroborate the claimed platform beautifully — QWERTY versus AZERTY versus QWERTZ — and it was tempting for exactly that reason. It was dropped after measurement: on a real Chrome it returned an EMPTY map, because the API needs focus and yields nothing in an unfocused or embedded context. A check built on it would have failed honest browsers sitting in a background tab. Presence is the part that holds; the map is the part that would have been a false positive.

How to resolve it

The API's presence follows the build, not the UA. Presenting a desktop identity from a mobile build (or the reverse) leaves the surface disagreeing with the string — use a build whose form factor matches the identity you present, rather than adding or deleting the property to paper over the gap.

Coherence over camouflage: why a plausible identity beats a hidden one
brand-hints-vs-realmbrave, duckduckgo and isSecureContext report identically in the page and in a fresh realm. globalPrivacyControl is shown but deliberately not asserted: no Chromium ships it natively, so on Chrome a page-world-only GPC means an extension defined it — DuckDuckGo Privacy Essentials alone has millions of users — and an extension ADDING a property is ordinary, exactly as it is for globals.

What a detector infers

Some of the cheapest signals to fake are the ones that say which browser this is: navigator.brave exists only in Brave, window.duckduckgo only in DuckDuckGo's browser, and isSecureContext reflects how the page was loaded. All three are trivially assignable on the page's navigator — and all four are read from the browser itself by a realm the browser builds fresh, which is why this check asks a new iframe the same four questions. A brand hint that exists in one realm of a browser and not in another is a strictly stronger signal than the brand hint ever was: it does not merely fail to prove the browser is Brave, it proves something is adding the property after the fact. The same holds in reverse for a hint that was deleted to hide a browser's identity.

How to resolve it

Do not add or remove brand properties on the page's navigator. A fresh realm gets them from the browser and will keep answering honestly, so the edit holds only in the realm you touched — and the disagreement it creates is louder than the value you were trying to change.

Coherence over camouflage: why a plausible identity beats a hidden one
location-origin-coherenceFor an http/https page, location.origin is exactly protocol + "//" + host. Other schemes are not applicable and report N/A.

What a detector infers

An origin is not stored next to a URL, it is serialised from it: for an http or https document, origin is exactly the scheme, followed by two slashes, followed by the host and any non-default port. The browser computes one from the other, so the two cannot drift apart on their own, and reconstructing origin from location.protocol and location.host is a complete test of whether they still agree. It is a cheap check and it catches a narrow but real thing — an origin rewritten by itself, without the scheme and host that produced it being rewritten to match. The check declines rather than fails on schemes where this serialisation does not apply: file:, blob:, data: and sandboxed documents legitimately produce opaque or scheme-specific origins, often the literal string "null", which is correct behaviour rather than a contradiction. Severity is warn, because the surface is narrow.

How to resolve it

location.origin is derived from the URL rather than stored beside it, so it cannot disagree with location.protocol and location.host in a real browser. If they disagree, one of them was written by hand — change the actual URL the document was loaded from instead.

Anatomy of a browser fingerprint: every signal, and why they must agree
navigator-vendor-vs-engine"Google Inc." on any V8 engine, the empty string on Gecko, "Apple Computer, Inc." on JavaScriptCore — matching the engine measured by the oracle, and identical in a fresh realm.

What a detector infers

navigator.vendor is not a preference or a setting — it is a string constant compiled into the engine. Every V8-based browser reports "Google Inc." without exception: Chrome, Edge, Brave, Opera, Vivaldi, Samsung Internet and Electron all share the value because they all share the engine. Gecko reports the empty string. JavaScriptCore reports "Apple Computer, Inc.", which is why Chrome and Firefox on iOS report it too — on that platform they are WebKit underneath, whatever their name says. Three values, defined by three engines. What makes this worth its slot next to the productSub check is the ANCHOR. productSub gates on the user agent, which is the one thing a spoof always changes; this gates on the engine ORACLE — the RangeError phrasing and Array-source measurement that no UA patch, CDP override or userAgentData shim can reach. So the question it asks is not "does this vendor match the browser you claim to be" but "does this vendor match the engine you provably are", and those are very different questions. The check also compares the value against a fresh realm, which is engine-agnostic and holds regardless: both realms read the constant from the same binary, so an assignment that reaches only the page world is visible immediately. An engine the oracle cannot name is declined rather than guessed at, so this can go quiet but never wrong. The evidence that this is read in the wild is unusually direct: navigator.vendor is a named evasion module in the mainstream stealth plugins — someone thought it was worth patching — and DataDome's captured payloads carry it as a collected field.

How to resolve it

The value is compiled into the engine, so it cannot be chosen independently of the engine running underneath. Setting it to suit a user agent leaves the oracle disagreeing, and the assignment holds only in the realm it was made in — a fresh iframe goes on reading the binary's own constant. If the vendor must read differently, the engine has to be different.

Coherence over camouflage: why a plausible identity beats a hidden one
keyboard-layout-single-originUnder a Windows claim, the ISO-102 key and the OEM_5 key carry glyphs a single real Windows layout DLL pairs together — not an angle bracket on one and a backslash on the other.

What a detector infers

A Windows keyboard layout is a single monolithic DLL that defines the extra ISO-102 key and the OEM_5 key together, and the pairing is not free. Layouts that put the angle brackets on the ISO-102 key — German, French, Spanish, Italian, the Nordics, Portuguese, Latin American — never also put the backslash on OEM_5; layouts that put the backslash on OEM_5, such as US and US-International, map the ISO-102 key to backslash too. So a getLayoutMap that reports IntlBackslash as an angle bracket while Backslash is a backslash is a combination no single Windows layout emits: it is the signature of a layout assembled key by key rather than loaded from a real driver DLL. The glyphs come from the OS keyboard driver, which is why the pairing is hard to fake coherently. The check is gated to a Windows claim on purpose, because that gate removes the one real false positive: on Linux with X11, setxkbmap us on physical ISO hardware can legitimately map the extra key to the angle brackets, so a genuine Linux desktop can show this pair — but it reports platform Linux and is never evaluated. Brave nulls navigator.keyboard, Firefox and Safari never shipped it, and an unfocused tab returns an empty map, so all of those are quiet. It is scored warn, and in practice often declines because the layout map is empty unless the page has focus and a physical keyboard.

How to resolve it

A Windows layout is one DLL, so its OEM keys move together rather than independently. Presenting per-key glyphs assembled to look right leaves the combination outside what any real layout produces. Drive the layout from a real OS keyboard layout rather than composing it key by key.

What is browser fingerprinting?
appversion-vs-useragentOn Blink and WebKit, appVersion is byte-for-byte the user agent minus its leading "Mozilla/". On Gecko the check reports N/A, because that engine does not mirror the UA there.

What a detector infers

navigator.appVersion is not an independent fact. In Blink and WebKit it is computed from the User-Agent at read time — literally the UA with everything up to the first slash removed — so it is the same sentence with eight characters missing. Overriding navigator.userAgent from page script replaces one reader and leaves the other quoting the original build, which is why a spoofed UA is so often betrayed by the property nobody remembers exists. PerimeterX collects both readers in one payload, next to productSub, platform and the rest of the NavigatorID surface, and lets the pair speak for itself. The check goes quiet on the entire Gecko family, and that restraint is the interesting part: Firefox deliberately answers a truncated form like "5.0 (Windows)" and does not mirror the UA at all, so there is nothing to compare and the honest move is silence. That includes Firefox with a UA override or a UA-switcher addon — the check simply has no power there, which is the price of not false-positiving. It was nearly built the looser way, asserting that appVersion's platform token must prefix the UA's; browser testing killed that variant, because Firefox for Android in desktop mode serves a Linux user agent while appVersion keeps saying Android, and one tap on an ordinary phone would have thrown a critical.

How to resolve it

Set the user agent at launch, or through the browser's own override, so every reader derives from one source. Assigning navigator.userAgent moves one reader and leaves appVersion — computed from the real UA when you read it — quoting the build underneath. A Chrome-family UA-switcher extension produces exactly this shape, so seeing it here does not necessarily mean automation; it means two readers of one fact disagree.

Coherence over camouflage: why a plausible identity beats a hidden one
trusted-types-value-invariantcreateScript returns a branded object (typeof is not "string") that stringifies back to its input; isScript reports true for that object and false for a plain string; and the object is an instance of the native TrustedScript where one exists.

What a detector infers

Trusted Types is a typed API, not a boolean feature flag, and that is the whole of the check. When you build a policy and call createScript(s), the browser is required to hand back a branded TrustedScript object — never the raw string — that stringifies losslessly back to s; isScript is required to be a type-guard that returns true for exactly those objects and false for a plain string; and where the browser exposes a native TrustedScript constructor, the object must be an instance of it. These are self-referential spec invariants: they hold on genuine Chromium, on Firefox's shipping implementation, and on the official W3C polyfill, and no reference corpus is involved. The reason they are worth asserting is that Trusted Types is a tempting thing to fake. reCAPTCHA's BotGuard loader depends on it directly — it creates a policy named "bg" and its bootstrap gates on eval(policy.createScript("1")) returning 1, so a browser that cannot produce a real, eval-able TrustedScript never gets past the loader. A stub trustedTypes installed to satisfy a naive presence probe, or even to satisfy this page's own function-toString integrity checks on createPolicy and createScript, gives itself away the instant its createScript OUTPUT is inspected: a stub returns the string it was handed, and the string is not a TrustedScript. That is a step deeper than any toString probe on the page, which only ever asks whether a function looks native — this asks whether the object that function returns obeys the function's own type. The check never evaluates the script, because eval interacts with a page's Content-Security-Policy; the lossless round-trip is the CSP-safe proxy for eval-ability. It also declines rather than fails on every honest edge: absent on Safari and older Firefox, and a CSP that refuses an unknown policy name is page configuration, not a browser tell.

How to resolve it

Do not stub window.trustedTypes to fake the API's presence. It is a typed interface: createScript must return a real TrustedScript that round-trips to its input and that isScript recognises, not the raw string. A stub satisfies a presence check and then contradicts its own type contract the moment its output is examined. If the API must exist, use the one the browser actually ships, or the official W3C polyfill — both honour these invariants.

How automation gets caught, layer by layer
ua-wire-vs-navigatorThe user agent our server received is byte-identical to the one this page reports — or is that string with a proxy's token appended, which is ordinary.

What a detector infers

Your user agent is asserted on two channels built by different layers: the network stack writes the header, and the renderer answers navigator.userAgent. A detector gets the join for free — PerimeterX HMACs the JavaScript-side string and lets its edge compare the result against the header it actually received, which is why the reverse-engineering study's recurring symptom is an HMAC computed perfectly and rejected anyway. An override applied in only one place is a self-contradiction that costs the detector nothing to find. There is no client-only way to run this check: User-Agent is a forbidden header name, attached below JavaScript, so not even a ServiceWorker's FetchEvent can see what was really sent — the server has to echo back what it received. The shape of the difference carries the meaning, and the check is built around that rather than around a verdict. A TLS-inspecting corporate proxy — Zscaler, Netskope, Blue Coat — APPENDS a product token to the outbound header while navigator.userAgent, which never leaves the renderer, stays clean. That is common in exactly the enterprise and VDI population an audit must not flag, so an appended token is reported as what it is. A platform group that disagrees is a different claim entirely: a middlebox does not turn Windows NT into Linux. The row is unscored for now on purpose — if a CDN in front of this site ever normalises the user agent before we see it, the check would fire for every visitor, and that would be our infrastructure rather than their browser.

How to resolve it

Set the user agent at a layer that owns both channels: at launch, or through the browser's own override, both of which move the header and the renderer together. Assigning navigator.userAgent from page script moves only the one the network never sees.

Anatomy of a browser fingerprint: every signal, and why they must agree
date-tostring-coherenceAt every instant, the GMT±HHMM the string renders equals minus getTimezoneOffset() read off the same Date, and the string is exactly toDateString() + " " + toTimeString().

What a detector infers

A Date's text is not opaque. ECMA-262 defines toString() as toDateString() + " " + toTimeString(), fixes the weekday and month tables to English so they are never localized, and bakes the UTC offset into the result as GMT±HHMM. So a single Date object answers the timezone question through paths that do not consult each other: the numeric one (getTimezoneOffset), and the one nobody remembers to hook — the offset the engine renders into the string. PerimeterX collects the raw toString() output in the same pass as getTimezoneOffset() and Intl.resolvedOptions().timeZone, then differences them server-side for free. The check reads four instants spread across the current year plus now, and three details in it are load-bearing rather than incidental. It parses hours and minutes SEPARATELY and signs the whole quantity, because a shortcut that reads only the hours flags every user in India, Nepal, Chatham and Newfoundland. It pins the probes to the current year, because an epoch-0 date reintroduces sub-minute Local Mean Time — Asia/Kolkata before 1906 was +05:53:20, which renders as GMT+0553 against a whole-minute API and manufactures a false contradiction out of a correct browser. And it never predicts DST: both readings come off ONE Date at ONE instant, so a zone whose rules we would have got wrong is still self-consistent and still passes. What the check permanently refuses to touch is the parenthesised zone NAME and the IANA id. The name renders in the browser's own ICU locale, Antarctica/Troll's name is literally the string (GMT+02:00), and Asia/Kathmandu resolves to the alias Asia/Katmandu — asserting on any of that would be both a false-positive minefield and a hidden reference table.

How to resolve it

A timezone is one host setting rendered by several readers that do not check with each other. Patching Date.prototype.toString in JavaScript leaves getTimezoneOffset and Intl telling the truth. Set the zone at the layer they all derive from — the OS, the container, or the browser's own timezone override, which reconfigures ICU underneath every reader at once and is invisible to this check by design. If you run a timezone-changing extension, this is what it looks like from outside: your setup contradicts itself, which is not the same as you being a bot.

What is a timezone / IP mismatch?
origin-storage-historyContextual state; zero is not a finding.

What a detector infers

Cookies, storage, IndexedDB, and same-tab history reveal only this origin's state. A fresh profile is legitimate.

How to resolve it

Nothing to fix.

profile-age-boundaryN/A.

What a detector infers

Privacy boundaries prevent reading global history or true profile age.

How to resolve it

Do not relabel origin state as global age.

account-reputation-boundaryN/A.

What a detector infers

Account reputation needs authentication and server history, neither of which this public audit has.

How to resolve it

Keep account risk in a consented authenticated flow.

Engine & OS oracles

js-engine-identifier-vs-uaAn ordinary browser measures an id matching the family its own UA names — e.g. id 80 = V8 on Chrome — and passes.

What a detector infers

Two values are read straight out of the JavaScript engine itself: the character length of the RangeError message thrown by `(-1).toFixed(-1)`, and the length of the Array constructor's own source text with every occurrence of its name removed. Their sum is a stable engine fingerprint — 80 identifies V8, 58 SpiderMonkey, 77 JavaScriptCore — because it comes from wording and source formatting compiled into the binary, on a surface no User-Agent string, CDP override, or `navigator.userAgentData` shim ever touches. The check then compares that measured family against the family the UA claims (Firefox/FxiOS → SpiderMonkey; Edg/Chrome/Chromium/CriOS → V8; iPhone/iPad/iPod or Safari-without-Chrome → JavaScriptCore). When the two disagree, a detector can infer that the identity presented in HTTP and JS headers was written by a layer above the engine, and that inference is reliable precisely because the engine's own error text is not part of any spoofing surface. Severity is critical. Crucially, when the measured id is not one of the three known values, or the UA names no family the check adjudicates, `pass` is null — indeterminate, not a failure, and it is excluded from the score. That branch matters: engines reword error messages between versions, so a check that failed on an unrecognised id would fire on every real browser running a future release. An honest detector distinguishes "this contradicts" from "I don't know".

How to resolve it

Make the User-Agent match the engine that is actually executing. This contradiction cannot be resolved from inside JavaScript: patching `navigator` leaves the RangeError text and Array constructor source untouched, so a Gecko or WebKit engine presenting a Chrome UA stays visible. Either run the engine the UA claims, or claim the engine you run.

What is CreepJS?
math-tanh-os-vs-uaThe last bit of Math.tanh matches the libm of the operating system the user agent claims — a browser saying Windows rounds the way UCRT rounds.

What a detector infers

IEEE 754 fixes how a double is stored but does not require transcendental functions to be correctly rounded, so every operating system's C math library ships its own polynomial approximation and they disagree in the final bit — the two largest implementations differ on roughly a quarter of all inputs, usually by one unit in the last place. For years this was invisible from JavaScript because V8 bundled its own copy of fdlibm and therefore computed the same answer everywhere. That changed in Chrome 148: V8 swapped Math.tanh to std::tanh, which calls straight through to whatever libm the browser is linked against — glibc on Linux, libsystem_m on macOS, UCRT on Windows. The check evaluates tanh at 0.7, 0.8 and 0.9 and requires all three to match one platform's measured constants; tanh(0.8) separates all three operating systems in a single call, and the other two corroborate it. tanh(0.5) is deliberately excluded because it is identical everywhere and carries no information. What makes this among the strongest signals available is where the answer comes from. It is not a value the browser reports about itself — it is arithmetic performed by a C library underneath the JavaScript engine, so no navigator override, no CDP patch, no UA-CH shim and no launch flag reaches it. A headless Chrome on a Linux server presenting a Windows user agent is contradicted by its own floating point. Note also what the check refuses to do: an unrecognised set of constants — musl, a BSD libc, Apple Silicon's vector routines — reports nothing rather than guessing, and Math.tanh is the only Math function worth reading this way, since every other one still routes through bundled llvm-libc and is byte-identical across platforms. Technique documented by Scrapfly; the constants were verified against a real Windows Chrome 148 before shipping.

How to resolve it

There is no JavaScript fix, and that is the point: since Chrome 148 this value is produced by the C library the browser links against, below anything a page, an extension or CDP can reach. Either run the browser on the OS it claims, or claim the OS it is actually running on — the arithmetic will keep disagreeing no matter what userAgent, userAgentData and navigator.platform are set to. The check reports N/A rather than a failure on Chrome 147 and earlier, where tanh is still bundled fdlibm, and on Android, ChromeOS and iOS, whose libm this check has no measured signature for. It also declines when a desktop UA arrives on touch-primary hardware: Chrome for Android's Request-desktop-site rewrites the UA to the Linux desktop string, which would otherwise defeat the Android decline and adjudicate Bionic's arithmetic against a table holding only glibc, libsystem_m and UCRT.

Anatomy of a browser fingerprint: every signal, and why they must agree
hyphenation-engine-osThe hyphenation engine matches the claimed OS: CoreFoundation (Finnish yes, Latin no) on Apple, Minikin (Latin yes, Finnish no) elsewhere. On a browser without loaded dictionaries it reads nothing, which is ordinary.

What a detector infers

When CSS asks a browser to hyphenate a long word, the break points come from a dictionary supplied by the operating system — and the dictionary SETS differ by platform, which turns hyphenation into an oracle for the OS family. macOS and iOS hyphenate through Apple's CoreFoundation, whose dictionaries cover Finnish, Polish, Catalan and Romanian. Windows, Linux, Android and ChromeOS hyphenate through Chromium's own Minikin, which covers Latin and Welsh but not Finnish. So two probes tell the two engines apart: a Finnish word hyphenates only under CoreFoundation, a Latin one only under Minikin. Measure the rendered height of each in a narrow box and you learn which hyphenator the machine is really using — a fact from the font and text-shaping layer, entirely independent of the libm-rounding oracle that Math.tanh gives. This is deliberately REPORTED and never scored, for two reasons that came out of measuring rather than reasoning. Minikin's dictionaries are delivered by Chrome's component updater after startup, so a fresh Chrome, an Electron app (measured: both probes fired blank), a Chrome-for-Testing binary or a locked-down enterprise build simply has no dictionaries and hyphenates nothing — an ordinary state for a large population, and no basis for a verdict. And the dictionary table is Chromium-specific: Firefox ships its own hyphenation data with different coverage, so the whole technique only reads a Blink browser. Where it does read, a disagreement is still worth showing — an Apple hyphenator behind a Windows user agent is the render layer contradicting the claimed OS — but naming the OS from dictionary coverage is the kind of table that drifts, so the audit shows the reading and leaves the judgement to you.

How to resolve it

Nothing to fix on an ordinary browser. If a profile presents one OS while hyphenating through the other's engine, the mismatch comes from the text-shaping layer, which a user-agent string does not reach — the dictionaries follow the real operating system, not the claimed one.

Anatomy of a browser fingerprint: every signal, and why they must agree
cpu-arch-nan-vs-uachThe architecture read from the quiet-NaN sign bit matches navigator.userAgentData's architecture hint — both x86, or both arm.

What a detector infers

IEEE-754 fixes the exponent and the quiet bit of a NaN but leaves the sign bit to the hardware, and the hardware disagrees: an x86 FPU produces 0xFFF8000000000000 for 0/0 with the sign bit SET, while an ARM one produces 0x7FF8000000000000 with the sign CLEAR. Reading that single bit reveals the physical CPU family, and it is produced by the silicon — no JavaScript override, no user-agent edit, nothing in the page can reach the floating-point unit. Against it stands navigator.userAgentData's architecture hint, which is merely a string the browser reports and a spoof can rewrite freely. Two independent sources for one fact, so a disagreement means the claimed architecture is not the one the processor actually is: an x86 server wearing an Apple-Silicon Mac's user agent, or the reverse. The check hangs on one detail that only measuring reveals. The dividend MUST be a runtime zero, not the literal 0/0: V8 folds a literal 0/0 to the canonical quiet NaN (sign clear) at compile time regardless of the host CPU, so on a real x86 Windows machine the literal reads as ARM — the exact false positive the technique warns about, confirmed live before this shipped. Math.random() times zero is still zero but opaque to the constant folder, so the division happens at runtime on the real FPU. Two honest limits keep it fair: the architecture hint exists only on Chromium, so elsewhere the reading is reported rather than asserted; and x86 emulation such as Rosetta runs x86 floating-point semantics and reports x86 on both sides, so it agrees rather than tripping.

How to resolve it

The NaN sign bit is set by the FPU, below anything a page or a CDP override can reach, so it cannot be brought into line by editing a value. If it disagrees with the architecture hint, the hint claims a processor the hardware is not — run the browser on the CPU it presents.

Anatomy of a browser fingerprint: every signal, and why they must agree
jsheap-triplet-orderingused ≤ total ≤ limit. Absent entirely on Firefox and Safari, which have no performance.memory — that is not a finding, the API simply does not exist there.

What a detector infers

This check replaces a retired one, and the swap is the lesson. The old check asked whether performance.memory.jsHeapSizeLimit was one of a handful of values V8 was known to use — a question answered against a table maintained by hand. It rotted exactly as such tables do: a stock Chrome 150 reports 4395630592, the table had never seen it, and the audit told an ordinary browser it was implausible. This asks a different question entirely: do these three numbers describe a possible heap? usedJSHeapSize counts the live bytes, totalJSHeapSize the bytes allocated to hold them, and jsHeapSizeLimit the ceiling neither may cross — so used must fit inside total, and total must fit under limit. That is answerable from the MEANING of the fields, needs no reference data, and will still be answerable in ten years. Magnitudes are never inspected, only ordering, and that restraint is deliberate: live Chromium reports a limit of exactly 2^32, precisely the suspiciously round number a magnitude check is tempted to reject as fake. It is real. The ordering also survives V8's value bucketing, because the quantisation is monotonic — rounding can move the numbers but cannot invert the pair — and the triplet is frozen within a synchronous task, so there is no sampling skew between the three reads. PerimeterX ships all three as separate fields and lets its backend difference them; the repo's own forged template declares a used size LARGER than its total, which is what a value invented field by field looks like from the outside. Anyone assembling a fingerprint has to keep related fields related, and independently-rolled numbers forget they were ever related.

How to resolve it

These are three views of one heap rather than three independent numbers. If you are setting them, keep the relation: live bytes inside allocated bytes, allocated bytes under the ceiling. Better, leave performance.memory native — V8 quantises the values already, and its rounding is monotonic, so a real heap can never invert the ordering no matter how the numbers are bucketed.

How browser stealth is measured: coherence, entropy, and headless tells

Worker realms

worker-realms-agreeAn ordinary browser: every worker realm that answers reports byte-identical userAgent, platform, hardwareConcurrency, deviceMemory and engine id — pass, severity critical.

What a detector infers

A page is not one JavaScript realm — it is several. The audit loads one probe script into all three worker kinds the platform offers: a dedicated Worker, a SharedWorker and a ServiceWorker, raced in parallel, each reporting its own `navigator` from inside its own scope. The check then diffs four fields against the window — userAgent, platform, hardwareConcurrency, deviceMemory — plus the computed engine id (RangeError message length + Array-constructor source length), flagging any worker value that is defined and stringifies differently from the window's. The mechanism a detector relies on is simple: those values originate from the same browser process and must be identical, so a divergence cannot be a browser variation — it can only be an override installed on the main-thread `navigator` object that never followed into `WorkerNavigator`, which lives in a separate realm with a separate global. The three tiers are not redundant. Tooling that accounts for this at all usually accounts for the dedicated Worker, because that is the one everybody tests; SharedWorker and ServiceWorker are reached less often and answer honestly for longer. The ServiceWorker in particular is the realm most audits never inspect, for a mundane reason worth knowing — registration rejects blob: URLs, so any probe built the easy way structurally cannot see it. Note also what the check deliberately does NOT assert on: language and languages are excluded, because Chrome has a long-standing legitimate divergence between window and worker there, and asserting on it would fail stock, unmodified browsers. A detector that knows when not to assert is what separates a signal from noise.

How to resolve it

A spoof applied to the main-thread navigator does not propagate into WorkerNavigator. Apply the same identity in every realm, or set it at launch/browser level so no realm needs patching at all. Patching only the realms that are easy to reach is worse than patching none: it converts a value into a contradiction, which is the more legible of the two. Realms that cannot answer — SharedWorker is absent on Android Chrome and in Firefox private windows, ServiceWorker needs a secure context — are excluded rather than counted, and if no scope answers at all the check reports pass:null. One more exemption is worth knowing: a userAgent disagreement confined to the ServiceWorker tier is reported but not counted. A single ServiceWorker instance serves every client in its scope, so the browser starts it from the browser process with the build's default UA and there is nowhere to put a per-tab override — which means Android WebView's in-app browsers (Instagram, Facebook, TikTok) and Chrome for Android's Request-desktop-site structurally cannot reach it, and would otherwise convict a completely stock browser. The exemption is narrow on purpose: every other field is still asserted on that tier, and a UA disagreement that ALSO appears on the dedicated or shared tier — the shape a page-world spoof makes — still counts, because those tiers do carry the embedder's override.

Cross-realm coherence: why a worker must agree with the page
worker-gpu-agreesAn ordinary browser: the worker's UNMASKED_RENDERER_WEBGL string matches the window's exactly — pass, severity warn.

What a detector infers

Inside each worker the probe creates an OffscreenCanvas(32,32), obtains a webgl (or experimental-webgl) context, and reads UNMASKED_RENDERER_WEBGL via the WEBGL_debug_renderer_info extension — the same parameter the window-side WebGL probe reads from a DOM canvas. The comparison is renderer-string equality between each worker reading and the main-thread renderer; only the renderer is asserted on, not the vendor. The mechanism: the GPU and its driver are process-level facts, so the renderer string a worker sees is produced by the same graphics stack as the window's. When the two disagree, a detector can infer that the WebGL renderer was rewritten in one realm only — typically a getParameter hook injected into the page context, where OffscreenCanvas in a worker never receives it. The unmodified value shows through and names the real hardware. Severity is warn rather than critical because the renderer string is a softer, more environment-dependent signal than the navigator identity — and the case where a worker has no GPU context, or the window reports no renderer, is not a fail at all: it resolves to pass:null, not applicable.

How to resolve it

If the renderer strings are being overridden, apply the same override in the worker realm as well, or leave them native. If no worker reports a GPU (OffscreenCanvas/WebGL unavailable there) or the window has no renderer, the check renders pass:null — not applicable.

What is WebGL fingerprinting?
worker-scopes-reachedAn ordinary browser: at least the dedicated scope answers and is listed, usually alongside the shared scope — informational, never a fail. Listing only the dedicated scope is also ordinary: SharedWorker is absent or restricted in some perfectly normal configurations.

What a detector infers

This row asserts nothing — pass is hardcoded null and severity is info. It exists to frame every worker-vs-window comparison in this section by naming WHICH realms actually answered. The audit attempts a dedicated Worker and a SharedWorker in parallel, each from a blob: URL with a 2500ms timeout, and lists whichever resolved along with the `self.constructor.name` each one reported for its scope (e.g. DedicatedWorkerGlobalScope, SharedWorkerGlobalScope). If none answer, the detail says so explicitly: the comparisons are unavailable, not passing. This matters because a null result and a pass are different epistemic states, and a reader looking at 'Every worker realm reports the same identity' has to know whether that means 'we checked two realms and they agreed' or 'nothing answered'. Also note what is deliberately absent here: there is no worker-source or blob-scheme check, because the audit spawns its own probes from blob: URLs and such a check would flag the implementation itself on every run.

How to resolve it

Nothing to fix — this is a framing row. If it reports that no scope answered, the worker comparisons above it carry no evidence either way and should be read as unavailable rather than clean.

How browser stealth is measured: coherence, entropy, and headless tells

Network & WebRTC

webrtc-ip-vs-connectionThe public address STUN reports and the address this page was served to are the same, meaning both paths leave through one exit.

What a detector infers

This is the one check on this page that cannot be answered from inside the page, and the only one that makes a network call. A browser has two independent routes to the internet: HTTP, which carried this page, and WebRTC media, which negotiates its own path over UDP. The check asks a public STUN server which address it saw the media path arrive from, asks this site's own edge which address the page arrived from, and compares them. They are two views of one question — where does this browser exit? — resolved by two independent stacks. When they disagree, media is not following the route the page took: a proxy or VPN configured only for HTTP leaves WebRTC to find its own way out, and any page running the same few lines of JavaScript reads the address underneath it. The comparison is deliberately confined to a single address family, because a dual-stack machine legitimately reaches STUN over IPv6 while fetching the page over IPv4, and calling that a leak would be wrong. Absence of a reflexive candidate is neither pass nor failure but a missing measurement — symmetric NAT, a UDP-blocking firewall, or a WebRTC-blocking extension each produce it. This row is reported but NEVER SCORED, and the reason is worth stating plainly: it is a fact about network topology rather than two sources contradicting each other, and from inside the page the ordinary case and the interesting case are the same shape. A corporate laptop behind an enterprise proxy or a cloud security gateway proxies HTTP only and lets media go out over raw UDP — a public HTTP address and a different public UDP address — which is indistinguishable from a proxied session leaking its real egress. No client-side test separates them, and an ASN or subnet heuristic would silence the genuine leak along with the false alarm. So the finding is shown in full and left for you to judge, rather than counted against a stock browser on a managed network.

How to resolve it

Route UDP through the same egress as HTTP so both paths share an exit. Where that is not possible, force WebRTC to relay-only — an ICE transport policy of 'relay' suppresses server-reflexive candidates entirely, so no direct address is ever offered. Disabling WebRTC outright also closes the gap, at the cost of a browser that cannot place calls, which is itself a mild oddity a site can notice.

What is a WebRTC IP leak?
webrtc-mdns-hostHost candidates carry randomised mDNS .local hostnames rather than literal LAN addresses — the default in every current major browser.

What a detector infers

When WebRTC enumerates the ways a peer might reach you, it offers host candidates describing your machine's own network interfaces. Historically that meant publishing the literal LAN address — 192.168.x.x, 10.x.x.x — to any page that asked, which let a site map the internal network of everyone who visited. Chrome, Firefox and Safari now all default to replacing that address with a random multicast-DNS hostname ending in .local: unique per origin, meaningless outside the local segment, and still sufficient for a real peer to connect. The check counts host candidates and asks which form they took. A literal RFC1918 address appearing today means that default has been switched off, which matters on two counts — it exposes your internal network layout to every site you load, and it is an unusual configuration in its own right. Severity is warn rather than critical because nothing here contradicts anything: it is a departure from a default, not two sources disagreeing, and legitimate enterprise policy disables it often enough that a stronger claim would be unfair.

How to resolve it

Leave mDNS ICE candidate obfuscation enabled. It is on by default in current Chrome and is normally switched off only by an explicit launch flag or by an enterprise policy predating the feature. If a managed environment disables it, that decision publishes every visitor's internal address to every site they visit.

What is a WebRTC IP leak?
webrtc-address-classChrome yields an mDNS `<uuid>.local` host candidate — passes; that is the LAN address being withheld, not leaked.

What a detector infers

Reading from the same fully offline `RTCPeerConnection` — built with **no `iceServers`**, so host-only gathering, no STUN, no packets — the first `typ host` ICE candidate's connection-address is extracted and classified: `mdns-local` (a `<uuid>.local` name), `rfc1918-private`, `link-local`, `cgnat`, `routable-public`, or `none`. Only `routable-public` fails; every other class passes. This is a deliberate correction of a well-known tool: CreepJS treats anything other than `0.0.0.0` as a leak, which reports Chrome's mDNS hostname as a leak when it is in fact **the obfuscation working** — the real LAN address was withheld and replaced with a per-origin random name, which is the privacy feature functioning exactly as designed. The signal that actually matters is narrower: a routable public address inside a host candidate reveals the real network identity of the machine regardless of any proxy in front of it, because the candidate is gathered from the local interface rather than routed. Severity is info, and info-severity checks are excluded from the score entirely — even a routable-public result is reported rather than counted, because it describes your network configuration rather than contradicting anything you claim. When the stack is unavailable, `pass` is null with the reason surfaced — a blocked WebRTC stack is a privacy setting or extension, a legitimate configuration, not evidence.

How to resolve it

Only a routable-public host candidate needs resolving: enable mDNS candidate obfuscation, or restrict the WebRTC IP handling policy so host candidates aren't gathered from the public interface. mDNS, RFC1918, link-local and CGNAT results need no action.

What is a WebRTC IP leak?
webrtc-extmap-engine-familyUnder a Chrome UA, the SDP carries libwebrtc-shaped extensions including a webrtc.org experiment URI, and no Gecko-only csrc-audio-level.

What a detector infers

This check generates an SDP offer and collects its a=extmap RTP header-extension URIs. Those URIs are compiled into the WebRTC stack itself: libwebrtc (Chromium) ships webrtc.org/experiments/rtp-hdrext experiment URIs that Gecko never carries, and Gecko ships csrc-audio-level. Unlike codec lists, this set does not vary with the GPU or the host, which is what makes it a stable engine-family signal. Scoring applies only when the UA matches Chrome/<digits> and the runtime is Blink; under that claim, two conditions are flagged — the presence of Gecko-only csrc-audio-level, and the absence of any libwebrtc experiment extension. Either indicates a Chrome UA presented over a WebRTC stack from a different engine family. The comparison is family-level only; no per-milestone extension set is asserted, because Finch gating makes two identical stock Chromes differ. Severity is warn. A non-Chrome UA, or WebRTC being unavailable or disabled, yields N/A — the code notes that this is an environment fact, not a finding.

How to resolve it

Present a UA that matches the WebRTC stack you actually run. Don't put a Chrome UA over Gecko's WebRTC — the RTP header-extension set is compiled into the WebRTC stack, so unlike codec lists it doesn't vary with the GPU or the host. Only the engine family is asserted here: the exact per-milestone set does move with Finch gating, so two stock Chromes of the same version can legitimately differ.

What is a WebRTC IP leak?
sdp-engine-signatureStock Chrome emits goog-remb / x-google-* and claims V8 — markers and claim agree, so it passes.

What a detector infers

An `RTCPeerConnection` is constructed with **no `iceServers` key**, which makes the probe entirely offline: the native stack builds a local SDP offer and gathers host candidates without contacting a STUN server or emitting a single packet. The offer is then parsed for Chromium-only markers — `goog-remb` and any `x-google-*` attribute — which libwebrtc writes into its own SDP and which no other WebRTC implementation emits. The check compares the presence of those markers against whether the UA claims V8: `claimsChromium === hasChromiumMarker`. If the two disagree, a detector can infer that the native media stack and the advertised identity come from different browsers, and that inference holds because the SDP is authored deep in C++ by the WebRTC stack — it is not a JS-visible string a userland shim rewrites. Severity is critical. Deliberately family-level rather than an exact codec-set match: `pass` is null when the UA names no adjudicable family, or when the offer contains no audio and no video sections at all. Matching exact codecs would misread Chromium builds without proprietary codecs, enterprise codec policy, and distro builds as contradictions. This check is only evaluated when WebRTC is reachable; a blocked stack is an environment fact, not a tell.

How to resolve it

Present a UA that matches the browser whose WebRTC stack is actually compiled in. The native stack writes its own manifest; a non-Chromium engine behind a Chrome UA is contradicted by the SDP it generates, and no JS-level patch reaches that text.

What does WebRTC SDP reveal about a browser?
connection-api-vs-engineThe row does not appear — either the UA claims V8 (where NetworkInformation legitimately exists), or navigator.connection is absent on a Firefox/Safari identity.

What a detector infers

This check compares an API's mere existence against the engine family named by the User-Agent. It reads two things: status.hasConnection (whether navigator.connection is present at all) and claimedEngine(ua), which maps the UA string to V8 (Chrome/Chromium/Edge/CriOS), SpiderMonkey (Firefox/FxiOS), JavaScriptCore (iOS browsers, or Safari without a Chromium token), or null when no family is recognized. The row is only added when navigator.connection exists AND the UA claims a non-null engine that is not V8 — and in that case it is emitted with pass:false unconditionally. The mechanism is surface-area rather than value inspection: NetworkInformation is a Chromium-only API, unshipped by Gecko and WebKit, so its presence is itself evidence of a Chromium engine underneath. When a UA string says Firefox or Safari, a detector observes an API that browser does not implement, and infers that the UA string was rewritten while the underlying engine's surface was not. That inference is reliable because it requires no measurement or tolerance — an object either exists or it does not, and the browser vendors' shipping decisions are public fact. Note the null branch: if claimedEngine returns null (an unrecognized UA), no assertion is made and the row never appears.

How to resolve it

Remove navigator.connection entirely when presenting a Firefox or Safari identity, so the exposed API surface matches the engine the UA names. The alternative resolution is the reverse: present a Chromium UA that matches the Chromium engine actually running. Do not try to make navigator.connection's values look Firefox-like — the contradiction is the object's existence, not its contents.

How automation gets caught, layer by layer
connection-rtt-quantizationPass — Chromium rounds rtt to 25ms steps, so any genuine reading is a multiple of 25.

What a detector infers

The probe reads navigator.connection.rtt from the NetworkInformation API, and the check runs only when that value is a number. The assertion is a single modulo: rtt % 25 === 0. The mechanism is that Chromium does not report a raw measured round-trip time — it deliberately rounds RTT to 25ms steps as a privacy measure, so every genuine reading is a multiple of 25 (0, 25, 50, 75, 100, …). This makes the value's residue class, not its magnitude, the discriminating signal. A detector reading rtt=87 learns something structural: 87ms is an entirely plausible round-trip time, but it is not a number Chromium's quantizer can emit, so the value did not come from Chromium's network stack. The inference is reliable precisely because it does not depend on whether the number looks realistic — a hand-picked "realistic" random value fails the modulo instantly, while the quantization grid is a fixed, documented property of the engine. Severity is warn; a value that is a multiple of 25 (including 0) passes.

How to resolve it

Round any reported RTT to a multiple of 25 so it lands on Chromium's quantization grid, or leave NetworkInformation untouched and let the engine produce the value. If navigator.connection is absent or rtt is not a number, the row is not emitted.

Spec invariants as lie detectors
connection-ect-vs-rttA slow effectiveType (3g/2g/slow-2g) comes with a non-zero rtt. A 4g bucket is the no-estimate default and is never scored.

What a detector infers

navigator.connection reports the link two ways that come from one estimator: effectiveType is a coarse bucket (slow-2g, 2g, 3g, 4g) and rtt is a round-trip estimate in milliseconds. Because both are derived from the same network-quality estimator, a slow bucket arrives with a slow rtt; the estimator never reports a slow connection with instant latency. A stealth build that stubs NetworkInformation to control its shape tends to freeze rtt at 0 while leaving a non-4g effectiveType in place, which claims exactly that impossible pairing. The check corroborates against a source the stub cannot touch: the page own PerformanceNavigationTiming, which records that a real network round trip actually happened, so the estimate is not ready yet cannot explain a zero. The gating is deliberate and heavy. A 4g bucket is never scored, because 4g is also what Chromium reports when the estimator has no reading at all, and the finding only stands once a navigation round trip has provably completed. Severity is warn rather than critical because CDP network throttling in DevTools and Lighthouse drives effectiveType and rtt through the same override, so a caller could set a cellular type with zero latency as a legitimate if artificial configuration.

How to resolve it

effectiveType and rtt come from one estimator and move together. If NetworkInformation is being presented, derive both from a single plausible link profile rather than leaving rtt pinned at 0 — or leave the API untouched, since it is Chromium-only and its mere presence already carries meaning.

How is a headless browser detected?

Clocks & ledgers

epoch-vs-monotonic-clockperformance.timeOrigin + performance.now() reconstructs Date.now() to within a few hundred milliseconds — they are two readings of one instant.

What a detector infers

A browser carries two physically different clocks: a monotonic counter anchored at performance.timeOrigin, and the wall clock behind Date.now(). The specification welds them — performance.now() is DEFINED as the distance from timeOrigin — so timeOrigin + now() reconstructs Date.now() by construction. Anyone who mocks time mocks one of them. Puppeteer's clock.install(), CDP's virtual time policy, and anti-detect browsers that shift the epoch to match a proxy's locale all hook Date and leave timeOrigin reading the real navigation instant. This is the purest one-fact-two-sources case on the page: you never need to know what a normal browser looks like, only that a browser must agree with itself. The engineering around it is what makes it fair. It samples fifteen times and keeps the tightest Date/perf/Date sandwich, so a preempted read on a loaded machine does not become evidence. Its tolerance is sized for Firefox's resistFingerprinting and Tor, which clamp timeOrigin, now() and Date.now() to a 100ms quantum INDEPENDENTLY — three clamped terms stack into a few hundred milliseconds of entirely legitimate error, so the margin is a measurement, not padding. And when the two do disagree it samples again 700ms later: a gap that MOVED is the system clock being stepped underneath us by an NTP correction, and is reported rather than scored; only a gap that holds steady is a contradiction. The document-age gate matters most of all. On macOS and Linux the monotonic base does not advance across system suspend while the wall clock tracks real time, so a laptop that slept eight hours with this tab open wakes with an eight-hour delta. A check that cannot tell a sleeping laptop from a liar has no business scoring anyone.

How to resolve it

performance.now() is defined as the distance from performance.timeOrigin, so the pair cannot drift apart on its own. Hooking Date.now, or installing a fake clock, moves one and leaves the other anchored to the real navigation. If time must be controlled, control it at the layer both clocks read from rather than patching one API and hoping nobody solves for the other.

How automation gets caught, layer by layer
navigation-timing-dual-apiEvery milestone reached on both APIs reconciles through timeOrigin to within a couple hundred milliseconds, and timeOrigin itself matches the legacy navigationStart.

What a detector infers

The platform kept two generations of one API alive. performance.timing speaks Unix epoch; PerformanceNavigationTiming speaks milliseconds since timeOrigin. Separate objects, separate code paths, the same eight moments in a page's life — and timeOrigin is the exchange rate between them. That redundancy is free ground truth: you can audit a browser's time origin without asking it for the origin, by solving for it from the pair. The lesson generalises well past clocks: whenever a platform ships a deprecated API beside its replacement, the two are a coherence oracle, because tooling that spoofs the modern surface almost never remembers the fossil underneath it. Three guards keep it honest. The deprecated interface's PRESENCE is a gate rather than an assertion, so the day Chrome finally removes performance.timing this check goes quiet instead of firing — its own rot makes it silent, never wrong. Any milestone reading zero on either side is skipped, because milestones read zero until reached and comparing a zero against timeOrigin plus zero manufactures a timeOrigin-sized error, which would be a guaranteed false critical on a page still loading. And a page activated from a prerender is excluded outright: it keeps timeOrigin at prerender start while the two APIs anchor differently by design, so any real site using speculation rules would trip a naive version.

How to resolve it

Both APIs record the same instants through different code paths, and timeOrigin converts between them. Anything rewriting the modern surface has to rewrite the fossil underneath it in lockstep — which is easier to achieve by not rewriting either.

How automation gets caught, layer by layer
resource-timing-vs-cssomA stylesheet that demonstrably fetched and parsed also appears in the performance timeline, alongside the other resources the timeline is already reporting.

What a detector infers

Fetching a resource writes two records: the DOM object you can see, and an immutable entry in the performance timeline. PerimeterX ships the diff of those two ledgers in every payload — a count walked off document.styleSheets next to a count taken from the Resource Timing API — because a mismatch means someone is editing one book and not the other. A harness that injects a script and then filters performance.getEntries() to hide it has not removed the resource; it has created a contradiction between the page's two accounts of what it downloaded. This check is also the clearest example on the page of why reading a detector's collection list is not the same as inheriting its inferences. Running PerimeterX's own counting function verbatim on an ordinary site gives 47 against 17, because @import'd and CSS-initiated subresources carry the css initiator type without being top-level stylesheet entries. Asserting count equality — the naive reading of the two fields — would flag the entire web. PerimeterX never asserts it client-side; it ships raw counts for a backend to score. So this does the opposite: it injects ONE probe of its own at a cache-busted same-origin URL, proves with the load event and readable cssRules that a real fetch and parse happened, and only then requires the timeline to contain it — and only once the timeline has already proven it reports other resources at all. It uses a PerformanceObserver rather than getEntriesByType because the resource buffer holds 250 entries and silently drops the rest, and this page fires enough probes to overflow it. The honest way to build a check like this is to make the browser calibrate it, and to refuse to speak unless the browser has shown you it answers this question at all.

How to resolve it

Nothing needs fixing when the two agree. If they disagree, the performance timeline is being filtered while the DOM is not — and the timeline is an append-only ledger, so hiding an entry from it is a contradiction rather than a removal. Note this check compares one specific URL and never the counts: @import'd and CSS-initiated subresources make the totals differ on perfectly ordinary sites.

How automation gets caught, layer by layer
wallclock-vs-monotonic-rateOver three windows, the two clocks agree on elapsed time to well within a tolerance derived from their own measured ticks.

What a detector infers

A browser owns two clocks and they are not the same clock. Date.now() is wall time: it can be wrong, it can be corrected, it can jump. performance.now() is monotonic from navigation: it cannot go backwards, but it stops when the machine sleeps. What they must agree on is RATE — over any interval, both must report the same number of milliseconds elapsed, because they are measuring the same physics. DataDome's VM captures Date.now() minus navigationStart into one register and performance.now() into another one instruction later, and ships both raw: it never needs to trust either clock, only to subtract them. This check deliberately uses the rate form rather than the anchor form (timeOrigin plus now() reconstructing Date.now()), which two of the audit's other checks already cover. The anchor is latched from the wall clock at navigation, so a single NTP correction after the page loads poisons it permanently and no amount of re-sampling recovers; the rate form is immune to a fixed offset by construction and answers the strictly different question. Two things keep it honest. It takes the best of three windows, because performance.now() rides the platform's monotonic counter and that counter stops across suspend — close a laptop lid mid-audit and one window shows hours of skew, while a clock ticking at the wrong rate corrupts all three. And its tolerance is derived from each clock's own measured granularity rather than a constant, which is what lets Tor Browser and Firefox with resistFingerprinting — both clamping to 100ms — pass, and what makes it widen automatically for a timer-precision preference whose value we could never tabulate.

How to resolve it

A fake-timer shim, a clock offset applied to sell a timezone, or a coarsened now() meant to blunt timing attacks all hook one time source and leave the other telling the truth. Note the severity: this is a warning rather than a contradiction, because suspending the machine mid-measurement is a legitimate cause — which is exactly why the check takes the best of three windows before it says anything at all.

How automation gets caught, layer by layer
perf-clock-grid-coherenceEvery PerformanceEntry timestamp lands on the same grid performance.now() itself exhibits — they share one clock.

What a detector infers

A timestamp's low bits are a fingerprint. DataDome ships its performance floats unrounded — 2253.7000000029802 rather than 2253.7 — and that is not sloppiness: the residue is the exact double round-off of a clock floored to a 100-microsecond grid, and shipping it raw is how the grid survives to the server. This check reads the same thing from the other side. It measures the smallest tick performance.now() will actually resolve, by spinning until the value changes, then checks that every PerformanceEntry timestamp the browser exposes lands on that same grid. They are one clock: an entry cannot resolve time more finely than now() itself does. Landing on a coarser grid is fine and never flagged — that is consistent, not contradictory. What it catches is that you cannot patch one clock. Coarsening or jittering performance.now() to blunt timing attacks leaves PerformanceEntry.startTime testifying against it, because the tool patched the function everybody reads and not the timestamps the engine had already written. That is also precisely why this is a warning and not a contradiction, and why its wording says a clock has been modified rather than anything about bots: the honest and common cause is a privacy extension doing exactly what its user asked. Two implementation notes carry the check. The grid is measured every run and never tabled, so a future change to the browser's clamp makes it go quiet rather than wrong. And the measured tick is refined against the data's own quotients before use — dividing large timestamps by a raw measured float accumulates enough error to flag a stock browser, which is not a hypothetical: the first version of this check reported 61 of 62 entries off-grid on a clean Chrome.

How to resolve it

If you did not change a clock here, nothing needs fixing and this row is not scored. If you did — or a privacy extension did on your behalf — the lesson is that the browser exposes the same tick through performance.now() and through every entry it timestamps, so blunting one leaves the other describing the original. Coarsening both together is coherent; coarsening one is louder than not coarsening at all.

How automation gets caught, layer by layer
navigation-origin-consensusEvery reachable surface — timeOrigin, the legacy timing milestones, the modern navigation entry, and chrome.loadTimes/csi where they exist — agrees about when this document started.

What a detector infers

One document has one navigation start, and Chrome exposes it through four different doors: performance.timeOrigin, the legacy performance.timing milestones, the modern PerformanceNavigationTiming entry, and the long-deprecated chrome.loadTimes() and chrome.csi(). There is one navigation timing record behind all of them, so they must agree. DataDome demonstrably holds several at once — its VM anchors its whole clock section on performance.timing.navigationStart, while the main tag independently reads the modern entry (it ships nextHopProtocol and entryType, which exist only there) and separately enumerates window.chrome's own keys to confirm loadTimes and csi are present and reachable. What makes this worth a check is what it asks that its neighbours do not. Headless patches famously re-add window.chrome.loadTimes to look like real Chrome, and a check that asks whether it exists, or whether it stringifies as native, is satisfied by a stub — this audit ships two such checks and both are honest about their limits. This one asks the stub what time the document started, and then makes three other doors check the answer. That is a question a stub can only answer correctly by doing the real work of tracking the navigation. Every arm is gated independently and contributes only if reachable, so a Chromium fork with no csi, a browser with no navigation entry, and a future Chrome that has finally removed the deprecated surfaces all degrade one arm at a time rather than failing. Two guards matter: a milestone reading 0 is the spec's did-not-happen sentinel rather than time zero, and treating it as a value flags a stock Chrome outright; and the tolerance is derived from the measured granularity of Date.now() rather than a constant, because Firefox with resistFingerprinting quantises the legacy and modern sides independently and a fixed tolerance would open a gap on a stock Tor Browser.

How to resolve it

Restore the value, not just the function. Chrome answers this question identically through four doors because one navigation timing record sits behind them; tooling hooks the door it has heard of, and the older or newer twin keeps telling the truth. Severity is a warning rather than a contradiction because Chromium forks and Android WebView ship window.chrome too, and a fork that reimplements loadTimes on a different time base is an honest browser.

How is a headless browser detected?

Automation surface

webdriver-offtrue is a finding; false or undefined is unclassified rather than evidence of a human.

What a detector infers

This check is intentionally one-way. The WebDriver specification defines navigator.webdriver as the browser's assertion that it is under remote control, so true is direct evidence. False or undefined is not a pass: launch flags and patched builds can suppress the value, so absence proves nothing and is reported N/A. Descriptor integrity is evaluated separately.

How to resolve it

Launch the browser without the automation flag (--enable-automation and the WebDriver-enabled launch path both set this). Clearcote's engine returns false natively. Do not patch this with a userland Object.defineProperty — the value would read false while the descriptor shape becomes a new contradiction.

Why does navigator.webdriver reveal automation?
webdriver-descriptor-integrityA native Navigator.prototype getter, no own property on the navigator instance, and the value false.

What a detector infers

Rather than reading the value, this check inspects the property's shape: it requires a getter on Navigator.prototype whose Function.prototype.toString output contains { [native code] }, no own shadowing descriptor on the navigator instance itself, and a value of exactly false. In a stock Chromium build all three hold, because webdriver is a spec-defined prototype accessor implemented in C++. The two common ways to make the value look clean each break a different invariant: deleting the prototype property (the undetected-chromedriver approach) leaves the read as undefined with no native getter, while defining a JS override installs an own descriptor whose getter stringifies to its own source instead of native code. So a detector can distinguish 'false because the engine says so' from 'false because something in JavaScript said so', and only the first is a shape a real browser produces. The check is skipped (N/A) when window.chrome is absent, since the invariant is Blink-specific.

How to resolve it

Govern the value in the engine so the native prototype getter itself returns false — clearcote does this. Remove any delete navigator.__proto__.webdriver or Object.defineProperty(navigator, 'webdriver', ...) shim; both are descriptor-shape tells even though the observed value looks correct.

Why does navigator.webdriver reveal automation?
automation-globalsNone of the known driver globals present, and no puppeteer_ / cdc_ / $cdc_ prefixed keys on window.

What a detector infers

This check tests a fixed list of roughly two dozen known names against both window and document (Selenium's __webdriver_evaluate / __selenium_unwrapped / _Selenium_IDE_Recorder, ChromeDriver's $cdc_asdjflasutopfhvcZLmcfl_, domAutomationController, Watir's __lastWatirAlert, Playwright's __pwInitScripts and __playwright__binding__), then adds a prefix scan over Object.keys(window) for anything starting with puppeteer_, cdc_ or $cdc_. It passes only when the combined list is empty. These properties are not fingerprint signals in the statistical sense — they are working artifacts the driver injects into the page to do its job, so their presence is direct evidence of a specific tool rather than an inference from probability. The prefix scan matters because ChromeDriver's cdc_ variable name is build-derived, so enumerating a static list alone would miss renamed variants.

How to resolve it

These come from the driver and its injected bootstrap, not from your code. Use a launch path that evaluates in isolated worlds and does not bind helpers into the main world — clearcote with a clean Playwright launch exposes none of them. If you inject your own helpers, keep them out of the page's global scope.

How do anti-bot systems detect automation?
runtime-enable-leakNo stack read without an attached Runtime client.

What a detector infers

An attached DevTools Runtime domain serialises console arguments and reads an Error stack accessor. Human DevTools does the same, so this is contextual and unscored.

How to resolve it

Detach Runtime.enable when unnecessary.

sourceurl-leakNo pptr: or UtilityScript marker.

What a detector infers

Puppeteer and Playwright evaluation labels can appear in main-world Error stacks.

How to resolve it

Keep controller evaluation out of the main world.

main-world-executionNo unexplained canary call.

What a detector infers

An early DOM canary records main-world calls, but page code and extensions can also call it, so this is contextual.

How to resolve it

Use an isolated world for controller helpers.

exposed-binding-leaksNo controller binding artifact.

What a detector infers

Exposed functions leave Playwright/Puppeteer registries, prefixes, source text, or __installed markers.

How to resolve it

Do not expose controller bindings to window.

csp-bypassThe data: script is blocked.

What a detector infers

The audit excludes data: scripts. Execution despite script-src is direct CSP-bypass behavior.

How to resolve it

Do not enable Page.setBypassCSP or bypassCSP.

chrome-objectA Chrome-claiming UA is accompanied by a present, truthy window.chrome; non-Chrome engines are not scored.

What a detector infers

This is a cross-check, not a probe of window.chrome's contents: it first decides whether the browser claims to be Chrome (a Chrome/ token in the user agent, or the presence of navigator.userAgentData), and only then requires that window.chrome exists and is truthy. Real Chrome always populates this object; older headless builds and stripped or minimally-shimmed environments did not, so a Chrome user agent arriving without it is two signals disagreeing about what the browser is. The N/A branch is what keeps the check honest — on a non-Chrome engine the absence of window.chrome is correct, so nothing is scored. Severity is warn rather than critical because modern headless populates the object and the tell is largely historical.

How to resolve it

Run a real Chromium build so the object is present natively, or stop claiming a Chrome UA on an engine that isn't Chrome. Hand-building a window.chrome does satisfy this check, but it only relocates the problem: as soon as such a shim also fills in chrome.csi or chrome.loadTimes in JavaScript, the native-stubs check catches those members by their toString output. An empty stand-in object clears both checks while still contradicting every other Chrome-shaped signal on the page, which is why the object's presence is scored as a warning rather than treated as proof of a real Chrome.

How is a headless browser detected?
chrome-native-stubschrome.csi and chrome.loadTimes are either native functions or absent — never JS-defined stubs.

What a detector infers

Where the previous check asks whether window.chrome exists, this one asks whether its members are genuine. It walks chrome.csi and chrome.loadTimes and, for each that is a function, stringifies it via Function.prototype.toString; a real built-in returns a body containing { [native code] }, whereas a JavaScript replacement returns its own source text. Any that stringify as JS are collected and fail the check. This is the mechanism that catches shimming libraries: they add these members to satisfy naive existence checks, but a function defined in JavaScript cannot stringify as native, so the very act of filling the gap creates a more specific artifact than the gap itself. Absence is treated as fine — the check only flags functions that are present and non-native. It is N/A on a non-Chrome UA or when window.chrome is missing entirely.

How to resolve it

Remove puppeteer-stealth-style polyfills of chrome.csi / chrome.loadTimes. Real Chrome exposes them as native functions or not at all, so the honest options are a real Chromium build or leaving them absent; a JS stub is strictly more revealing than the missing member it replaces.

Why does Function.toString() reveal a hooked function?
permissions-notification-bugThe two APIs agree; specifically, not query state 'prompt' alongside Notification.permission 'denied'.

What a detector infers

Two independent APIs describe the same underlying notification permission: navigator.permissions.query({name:'notifications'}) returns a PermissionStatus state, and Notification.permission returns a string. This check awaits the query and flags exactly one combination — state 'prompt' while Notification.permission is 'denied'. In a coherent browser these cannot disagree, because they are two views of one stored setting; the pair diverges in certain headless configurations where the permission backend and the Notification interface are wired to different defaults. A detector reading both and comparing them needs no heuristic — the contradiction is decisive on its own, which is why the combination became a well-known probe. Any exception during the query is swallowed and the check passes, so an environment lacking either API is not penalised.

How to resolve it

This is a property of the browser build and its headless mode rather than something to patch from page scripts — a build whose permission backend is coherent reports matching states. If you are overriding either API, override both from the same source of truth so they cannot diverge.

How is a headless browser detected?
permissions-query-stackZero-arg permissions.query() rejects with a TypeError whose stack contains no forwarder frames.

What a detector infers

This probe calls navigator.permissions.query() with exactly zero arguments and inspects the error the native arity check produces. Two outcomes are informative. If nothing throws or rejects at all, the native arity check is simply gone, which means query is a JavaScript replacement rather than the engine binding. If an error does arrive, the check reads its stack and looks for .apply or Reflect.apply frames — a JS forwarder wrapping the native function leaves those frames behind because V8 renders the forwarding call in the stack trace it builds. The inference is reliable because the stack is generated by the engine at throw time, not by the wrapper, so a hook that faithfully fakes Function.prototype.toString still shows the forwarder here. Severity is warn, and the check is scored only on Blink with navigator.permissions present; otherwise it reports N/A.

How to resolve it

Don't wrap navigator.permissions.query in JavaScript. If the permission response needs to differ, change it at the engine level rather than installing a JS function that forwards to the native one via .apply — faking toString does not remove the forwarder frame from the arity error's stack.

Why toString reveals a hooked function
timer-id-sequencingA newly created realm hands out consecutive positive integers from a single counter shared by setTimeout and setInterval — 1, 2, then 3.

What a detector infers

The number setTimeout hands back is not a token, it is an index into the realm's own timer table — so it carries structure that a fabricated handle does not. A realm that has just been created has an empty table, hands out its first slot, then its second, and setInterval draws from the SAME counter rather than starting one of its own. Measured on a stock Chrome: a newborn realm returns 1, then 2, then 3 for setInterval, while the page's own window had already reached 47. The check therefore does its measuring inside a PRIVATE iframe it creates and destroys, not in the page and not in the audit's shared realm. That is the whole trick. The main window's counter is racing every timer that React, a chat widget or an extension schedules, so consecutive numbering there proves nothing and would fail honestly at random; a virgin realm is the only place the sequence is deterministic. Consecutive numbering is asserted only on V8, because the specification says merely that the handle is a 'user-agent-defined integer' — demanding consecutiveness of every engine would be inventing a rule rather than reading one, so elsewhere the check falls back to what is universally true: the handles must be positive integers and they must increase. What this catches is a wrapper that issues its own handles — randomised to look unpredictable, or drawn from a separate counter per function — which breaks an invariant that costs nothing to verify.

How to resolve it

Leave setTimeout and setInterval native. If they must be wrapped, the wrapper has to return the real underlying handle rather than one of its own, and setInterval must continue the same sequence setTimeout is drawing from — which is easier to achieve by not wrapping them at all.

How automation gets caught, layer by layer
document-visibility-coherencedocument.hidden is true exactly when document.visibilityState is "hidden" — always, by definition.

What a detector infers

document.hidden and document.visibilityState are two views of one piece of state, and the specification defines the first in terms of the second: hidden is true exactly when visibilityState is the string "hidden". There is no state of a real browser in which they disagree, because one is computed from the other. This is worth checking because faking foregroundedness is a common and rational thing for automation to do — browsers aggressively throttle timers and rAF in background tabs, which slows a headless run to a crawl, so tooling patches the page into claiming it is visible. And when it does, it tends to patch the property a page is most likely to read while missing its twin. A detector needs no baseline to notice: it reads both and asks whether they can both be true.

How to resolve it

Never patch one of these without the other; the spec derives hidden from visibilityState, so a disagreement is not an unusual browser but an impossible one. If the goal is to escape background throttling, keep the page genuinely foregrounded — or accept the throttling — rather than claiming a visibility the browser is not in.

How is a headless browser detected?
plugins-idl-index-wraparoundplugins.item(2^32 + 1) returns the same object as plugins.item(1) — the modular index reduction a real PluginArray performs, matching the control NodeList in the same realm.

What a detector infers

A real navigator.plugins is a PluginArray — a C++-backed IDL collection — and its indexed getter runs the Web IDL unsigned-long conversion, so item(2^32 + 1) is item(ToUint32(2^32+1)), which is item(1). A spoof that replaces plugins with a plain JavaScript object or array to control its contents loses that wraparound, because a JavaScript object property access performs no modular reduction. So asking for a deliberately out-of-range index tells a genuine binding apart from a fabricated one, and it does so without reading a single plugin name — it is a question about the nature of the collection, not its contents. The check is control-gated so it can never misfire: before judging plugins it confirms that this realm binding layer wraps at all, using a NodeList nobody has any reason to spoof (querySelectorAll over html, head and body). If that control does not wrap, the check declines rather than holding plugins to a rule this engine does not follow. It is scored warn rather than critical for one honest reason: a legitimate page-world fingerprinting extension can also replace plugins with a JavaScript object, so the signal means this is not the native collection, not this is a bot.

How to resolve it

Do not replace navigator.plugins with a JavaScript array or object. A real PluginArray is a C++ IDL collection whose indexed getter reduces the index modulo 2^32, and a JavaScript stand-in cannot reproduce that. A plugin list that must be presented has to come from the browser, not from page script.

What is browser fingerprinting?
storage-quota-worker-vs-mainThe quota reported in the window and inside a same-origin worker agree to within 5% — they read one partition through one QuotaManager.

What a detector infers

navigator.storage.estimate().quota describes one storage partition, and a same-origin worker shares that partition — a single QuotaManager in the browser process answers both, keyed by one storage key. So the window and the worker must quote the same quota. This is the cleanest recorded instance of the main-world-only spoof failing: a page patched all three quota APIs from the main thread, and a detector simply read the quota inside a Worker, which a page-world override never reaches. The maintainer of the tracker said it in as many words — reading quota from a Web Worker bypasses page-script patches entirely — and the eventual fix was a binary-level flag, because a main-world-only override is incoherent by construction. The check compares quota only, never usage and never magnitude: no threshold, no ratio, nothing that could rot or unfairly flag a small disk or an incognito window. A 5% relative tolerance absorbs the few-megabyte drift a free-space-derived quota can show between two reads and is still far tighter than the documented spoof gap. It goes quiet rather than failing whenever StorageManager is absent on either side, the worker is blocked by a strict CSP without blob: in worker-src, or it times out.

How to resolve it

Quota is answered by one manager in the browser process, keyed by the storage partition, so it cannot differ between the window and a same-origin worker. A main-world override of navigator.storage.estimate does not reach a worker own navigator. Change it below JavaScript, or leave it native.

Coherence over camouflage: why a plausible identity beats a hidden one
clock-origin-coherenceperformance.timeOrigin + performance.now() lands within a second of Date.now() — two independent clocks agreeing on one instant.

What a detector infers

A page has two clocks. performance.timeOrigin is the wall-clock moment its timeline began; performance.now() is the monotonic time elapsed since. Their sum therefore reconstructs Date.now() — the same instant, reached through an entirely separate mechanism. That redundancy is the check. Automation very commonly overrides Date.now(): to skip waits, to defeat timing analysis, or to make a session look older or newer than it is. It much more rarely touches performance, because performance.now() is monotonic, is used by the browser's own machinery, and breaks pages when it moves. Override one and not the other and the two clocks disagree by exactly the amount that was faked — a hidden time becomes a visible contradiction. The tolerance is deliberately loose, at a full second. Legitimate drift is real but small: an NTP correction or a user changing the system clock mid-visit moves the wall clock while the monotonic one keeps counting, and Firefox's resistFingerprinting and Tor Browser both round performance.now() to 100ms. None of those approach a second, whereas a faked clock is rarely subtle — it is usually minutes or hours. Severity is warn rather than critical for exactly that reason: a clock correction is an innocent explanation that exists, even if it is rare.

How to resolve it

Do not override Date.now() on its own. performance.timeOrigin plus performance.now() reconstructs the same instant from a separate monotonic clock, so moving one and not the other converts a hidden time into a contradiction that needs no baseline to spot. If the clock genuinely must differ, move the machine's clock rather than the page's Date.

How automation gets caught, layer by layer
user-activation-vs-interactionContextual only: hasBeenActive should be true, because the audit is reachable only by pressing a button and pressing a button grants activation. Not scored — see above.

What a detector infers

This audit only runs when you press the Run button, and pressing a button is exactly what grants user activation — by pointer, by Enter or Space on a focused control, or through a screen reader, all of which travel the browser's real input pipeline. So by the time the check executes, a genuine gesture has necessarily happened and navigator.userActivation.hasBeenActive must be true. The flag is sticky for the window's lifetime, so nothing that happens afterwards can take it away. What makes this worth asserting is the precise thing the flag distinguishes: element.click() and dispatchEvent() do NOT grant activation, because activation is a property of the input pipeline rather than of the event object. A run that reaches this line with hasBeenActive still false was therefore started by script, not by a person. There is a candid story attached: during development this check read false, and the reason was that the page was being driven by synthetic onClick calls to test it — the check was working exactly as designed and catching its author. Note what it does NOT claim. CDP's Input.dispatchMouseEvent — the mechanism under Puppeteer and Playwright — injects at the browser's input pipeline above the renderer, so it DOES grant activation and passes this check. Like Event.isTrusted, this separates page script from the input pipeline; it says nothing about who is at the other end of that pipeline. This row is CONTEXTUAL and never scored, and the reason is a piece of honesty worth recording. The reasoning above says a real click always grants sticky activation, so hasBeenActive must be true by the time this runs — and that is probably right. But it was never verified: no tool available could produce a genuine human click, so every measurement taken showed the FAILING value and not one showed the passing one. A scored check whose pass case has never been observed is a check that fires on every real visitor if its reasoning is wrong, which is the worst thing this page could do. So it reports and explains rather than counts, until a human click is confirmed to turn it true. The row is also N/A on Gecko and WebKit, where navigator.userActivation does not exist at all.

How to resolve it

If you are driving this page programmatically, note that a JavaScript-level .click() or a dispatched event never grants activation — the browser's input pipeline does. CDP-injected input satisfies this check trivially. There is nothing to fix for a person who pressed the button.

What is behavioural bot detection?
scroll-alias-coherencepageXOffset equals scrollX and pageYOffset equals scrollY, exactly, because each pair is one value with two names.

What a detector infers

window.pageXOffset is not a second scroll position, it is a legacy alias of window.scrollX — the specification defines them as the same value and Blink backs both names with one getter. The same holds for pageYOffset and scrollY. Two names, one number, so they are incapable of disagreeing in a real browser. That makes this a complete test of whether both names still reach the same place, and it costs two property reads. The shape it catches is a familiar one: a value rewritten through the name a script is expected to read, while the alias nobody remembered keeps reporting the truth. It is the same class of mistake as patching navigator.language and forgetting navigator.languages[0]. Worth noting that Kasada reads pageXOffset and pageYOffset and never scrollX or scrollY — reading only one name of an aliased pair is what makes the pair assertable by someone who reads both.

How to resolve it

Do not patch one name of an aliased pair. If a scroll position must differ, scroll the document — the alias is not a second source that can be updated to match, it is the same source, and the disagreement itself is the tell.

Anatomy of a browser fingerprint: every signal, and why they must agree
document-focus-vs-realmWhenever a child frame reports focus, this document reports focus too. Both unfocused, or parent-focused-child-not, are ordinary states.

What a detector infers

document.hasFocus() is true when the document OR any of its descendants holds focus, which makes it hierarchical rather than local: a focused child frame necessarily means its parent document reports focus too. That implication is the only thing assertable here, and the check asserts precisely it and nothing more. Both documents reporting false is the ordinary state of a background tab; the parent reporting true while the child reports false simply means focus lives somewhere else in the page. Only the reverse — a child that claims focus while its parent denies it — is impossible, because focus cannot exist inside a frame without existing in the document that contains it. The reason it is worth checking at all is that faking foregroundedness is a rational thing for automation to do: browsers throttle timers and rAF in background tabs, which slows a headless run badly, so tooling patches the page into claiming focus and visibility. Patching a hierarchical property in one document without its ancestors is the natural mistake, and it is what this reads. Severity is warn.

How to resolve it

Do not patch document.hasFocus() to claim foregroundedness — focus is hierarchical, so a claim made in one document has to hold for its ancestors as well, and a child that outranks its parent is impossible rather than merely unusual. Foreground the page rather than asserting that it is.

How is a headless browser detected?

Error subsystem

error-dialect-realm-agreementEvery reachable realm returns a byte-identical signature — the same eight messages, the same stack dialect, the same location for the stack property.

What a detector infers

An engine's error messages are a dialect hiding in plain sight. Ask V8 to read a property of null and it says "Cannot read properties of null (reading 'x')"; SpiderMonkey says "x is null"; JavaScriptCore says "null is not an object (evaluating 'x')". None of that is specified — it is wording compiled into the binary. The check evaluates one shared source string in the page, in a fresh iframe realm and in a worker, provoking eight deliberate throws across eight error classes (reading a property of null, of undefined, calling a non-function, reducing an empty array, toFixed(999), Array(-1), JSON.parse('{'), decodeURIComponent('%')), and adds two structural facts: the stack's shape classified only as AT-style or at-sign style, and where the `stack` property lives (an own accessor, an own data property, or on the prototype). One engine serves all three realms, so all three must answer identically. DataDome is provably harvesting exactly this: one of its fields base64-decodes to "WorkerCaughtErr: Err: TypeError: Cannot read properties of null (reading 'getExtension')" — it builds an OffscreenCanvas inside a Worker, gets a null context back, calls getExtension on it, and ships the resulting message. The failure is the payload; it is not recovering from that error, it is collecting it, from the realm most tooling forgets. Two design choices are worth stating because they are what keep the check honest. It never names an engine, a version or a phrase, so there is no table to go stale — an engine nobody has ever seen simply agrees with itself and passes. And all three realms must evaluate the identical source bytes, because every engine quotes your own source text back inside these messages: if the page evaluated an inlined copy while the worker evaluated a string, our own minifier would rename the variables in one and not the other, and Firefox and Safari would then genuinely report different text for the same operation, failing a completely stock browser.

How to resolve it

Error messages are produced by the engine, and the engine is shared by the page, its iframes and its workers. Anything rewriting them in one realm cannot reach the others, because a fresh realm is constructed straight from the binary. If a stack or a message must be hidden, note that it has to be hidden identically in every realm at once — which is considerably harder than leaving it alone.

Why does Function.toString() reveal a hooked function?
error-stack-depth-fidelityWith the frame limit raised and confirmed, calling sixteen levels deeper produces exactly sixteen more frames.

What a detector infers

DataDome's bytecode VM does something small and sharp: new Error(), read .stack, fold a hash over the text — we can read the exact opcodes, including its fallback to an empty string for engines that have no stack at all. So the shape of that string matters to a detector, which makes rewriting it tempting. This check does not compare your stack to anyone else's, because there is nothing to compare it to. It calls a function four levels deep, then twenty levels deep, and checks that the stack noticed the sixteen extra calls. The ground truth is generated at runtime — we know exactly how many calls we made — so there is nothing to maintain and nothing that can go stale. A real stack always notices; a canned string never does. The subtlety here is the one that nearly shipped as a bug, and it is worth knowing if you ever write this test yourself: V8's default Error.stackTraceLimit is 10, so the frame count SATURATES. Measured on a stock Chrome, depth 4 gives 8 frames and depth 20 also gives... 10. The naive form of this check — deeper chain means more frames — therefore computes a difference of 2 rather than 16 and accuses every unmodified Chrome on earth. Only after raising the limit AND confirming by reading it back does the response become linear: the same browser then reports 8 frames at depth 4 and 24 at depth 20, a difference of exactly 16. A difference between 1 and 15 is reported without being counted, because engines legitimately elide inlined frames and that behaviour does not get to accuse anyone.

How to resolve it

Error.stack is generated by the engine from the live call chain. A fixed or edited string satisfies anything that reads it once and fails the moment someone calls from a different depth and counts — which is cheap to do. There is no inexpensive way to fake this, which is the entire reason it is worth measuring.

How automation gets caught, layer by layer
structured-stack-vs-string-stackEvery frame of our own probe chain that the structured rendering names also appears in the string rendering.

What a detector infers

Chrome describes one call chain twice. Once as the human-readable string on Error.stack, and once as structured CallSite objects, through a hook called Error.prepareStackTrace. DataDome only ever reads the string — its VM hashes the text, and the field it ships names the originating script URL — so the structured rendering is a second witness to the same event that the detector itself never consults. If the string omits a function the structured view says was definitely on the stack, the string was edited after the engine wrote it; scrubbing an injected script's frame out of a hash is the motivated behaviour here. Two lessons here were found by running the check rather than reasoning about it, and the first one shipped as a bug. V8 formats a stack LAZILY, on first access to .stack, through whatever prepareStackTrace is installed at that moment — so reading the string rendering while our own array-returning hook was still active handed back the CallSite array instead of a string, and testing `array.includes(name)` looks for an element equal to that name rather than a substring. Every frame therefore read as missing, and the check accused every stock V8 browser of editing its stack. The fix is an ordering: construct the Error inside the call chain, read the CallSites under the hook, restore the hook, and only then read .stack — which yields both renderings of the same chain. The second lesson: in V8, `stack` is an OWN ACCESSOR on each Error instance, and Error.prototype has no stack property at all. That means a spoofer's per-instance getter intercepts BEFORE prepareStackTrace ever runs — so the obvious implementation, throwing an Error and reading its .stack, hands the spoofed string straight back and goes quiet against the exact tool it was built to catch. The independent reading has to come from Error.captureStackTrace applied to a plain object we own, which a hooked Error constructor cannot reach. The check speaks only when V8's own documented API is present, native and functioning: if captureStackTrace is missing, or is an npm shim rather than the real thing, or if something legitimate already owns prepareStackTrace — Sentry and source-map-support both do — it reports nothing rather than guessing.

How to resolve it

Both renderings are generated by the engine from the same call chain, so editing one in isolation is visible from the other. If the goal is to keep an injected script's URL out of a stack hash, note that the structured view still names it — and that the tool doing the scrubbing is what created the disagreement in the first place.

Why does Function.toString() reveal a hooked function?
error-stack-livenessThe name of a function created microseconds ago appears in the stack of an Error thrown inside it.

What a detector infers

A stack trace is not a string, it is a measurement of the present moment — a function of who called whom, from what URL, at what column, right now. PerimeterX throws a deliberate null dereference and keeps the wreckage, and its captcha path collects four stacks from four error types, because in the study's own words one stack is a bot taking a shortcut. The forgery is in that repo verbatim: a helper that returns four frozen template strings with a per-Chrome-major lookup table, which the authors themselves document breaking on every Chrome bump. So this check asks the engine a question it could not have known the answer to in advance. It mints a function name at runtime and throws inside it: a canned stack has nowhere to hide, because it cannot contain a name that did not exist when it was written. Two implementation details are load-bearing. The positive control reads its own identifier off Function.name rather than hardcoding it, because this bundle is minified and a hardcoded name would never match — the check would silently self-disable and pass everything forever. And the control itself is the guard that keeps us from flagging WebKit: if this engine does not name an ordinary declared function in its stacks at all, we have no business asserting anything and the check reports N/A. A V8 detail worth knowing: stack is an own accessor on each Error instance and Error.prototype has no stack at all, so a naive prototype-level shim is shadowed and never runs. What the check deliberately does NOT do is assert stack SHAPE — shape needs a version table that rots, exactly like the heap lattice this release retires. Liveness needs nothing but the browser itself.

How to resolve it

Error.stack cannot be replayed, only re-derived. Returning a fixed string from a patched stack getter answers a question the caller has not asked yet, and a runtime-minted name exposes it instantly. If a stack must be shaped, it has to be generated from the real call chain rather than replayed from a template — which is the same amount of work as not patching it.

Why does Function.toString() reveal a hooked function?

Native function integrity

tostring-nativeBoth probes return the native-code placeholder; pass.

What a detector infers

The check stringifies two functions and requires both to match /\{\s*\[native code\]\s*\}/: Function.prototype.toString itself (read as Function.prototype.toString.toString()), and navigator.permissions.query as a spot-check target. Per spec, a function implemented by the engine has no JavaScript source to return, so toString yields the NativeFunction placeholder; a function defined in JS returns its own source text. A userland layer that wraps a built-in has to keep the wrapper looking native, so it typically replaces Function.prototype.toString with a JS function that special-cases the wrapper — and that replacement returns its own source text, which is exactly what the first probe reads. The second probe covers the simpler case where permissions.query itself was swapped for a JS function. A detector that sees Function.prototype.toString reporting JS source infers that something in the page is actively rewriting the identity of its built-ins, which is a stronger signal than whatever the wrapper was concealing. This is a naive-tamper probe by design: a well-formed Proxy around the original is stringified by the engine as a NativeFunction and passes both probes, so proxied wrappers have to be caught by the other checks in the battery (descriptor shape, cross-realm reads).

How to resolve it

The contradiction comes from a JS-injection stealth layer proxying or wrapping Function.prototype.toString. Remove userland evasion shims that patch toString and let identity control happen at the engine level, where built-ins are genuinely native and stringify as such with no wrapper to hide. Note both probes are wrapped in safe(..., true), so a probe that throws is treated as a pass rather than a tell.

Why does Function.toString() reveal a hooked function?
navigator-descriptorsNo own descriptors on navigator; all five resolve via native getters on Navigator.prototype.

What a detector infers

For deviceMemory, hardwareConcurrency, language, languages and platform, the check reads the property descriptor twice. First Object.getOwnPropertyDescriptor(navigator, prop): in a stock browser this returns undefined, because these are accessors defined on Navigator.prototype, not own data properties on the instance. Any own descriptor is a tell — a `value` field means someone assigned a plain data property, and a `get` field is only accepted if that getter itself stringifies as [native code]. If no own descriptor exists, it falls back to Navigator.prototype and fails only if a descriptor is present without a `get`, i.e. the prototype accessor was replaced by a data property. The mechanism is reliable because the shape of the descriptor is structural: Object.defineProperty(navigator, 'platform', {...}) cannot reproduce a native prototype accessor, and a plain `navigator.platform = ...` silently fails against a getter-only property, so any value that did land is provably userland.

How to resolve it

Drop the Object.defineProperty overrides on navigator (or the library installing them) and set these values at the engine/binary level instead, so the prototype accessor itself returns the intended value and the descriptor shape stays indistinguishable from stock.

Why does navigator.webdriver reveal automation?
native-integrityAll present targets stringify as native and expose no .prototype; pass.

What a detector infers

This runs a small battery over eight engine-owned callables — Function.prototype.toString, navigator.permissions.query, HTMLCanvasElement.prototype.toDataURL, CanvasRenderingContext2D.prototype.getImageData, WebGLRenderingContext.prototype.getParameter, HTMLElement.prototype.attachShadow, and the Navigator.hardwareConcurrency and HTMLIFrameElement.contentWindow getters — and applies two tests to each. The first is the [native code] stringification. If that passes, the second runs: `'prototype' in fn`. Built-in methods and accessors are non-constructors and per spec carry no .prototype property, whereas an ordinary `function(){}` used as a wrapper gets one automatically. So a shim that returns a convincing string from toString can still be given away by a .prototype the original never had. Targets that aren't functions in this browser are skipped rather than failed, so an unsupported API doesn't count against the result.

How to resolve it

Each entry named in the failure detail is a built-in that has been replaced or wrapped by a JavaScript function. Replace the wrapper approach with engine-level control of the underlying value. Note what this battery does and does not cover: an arrow function or a method-shorthand avoids the .prototype tell but still returns its own source from toString, while a Proxy around the original passes both probes — the engine stringifies a proxied callable as a NativeFunction and forwards the 'prototype' lookup to the native target. Proxied wrappers are surfaced by the descriptor-shape and cross-realm checks instead.

How do anti-bot systems detect automation?
iframe-coherenceIframe navigator values equal the main realm's, and its natives stringify as native.

What a detector infers

The check appends a hidden same-origin iframe to documentElement and reads its contentWindow — a brand-new JavaScript realm with its own fresh copies of navigator, Function and HTMLCanvasElement. It compares platform, hardwareConcurrency, webdriver and userAgent against the top realm, and additionally stringifies permissions.query and HTMLCanvasElement.prototype.toDataURL using the iframe's own Function.prototype.toString, requiring both to contain [native code]. This is decisive because a JS patch runs against the objects of the realm it executed in; a realm created afterwards is populated directly by the engine and is untouched, so main-thread-scoped spoofing produces a contradiction between two realms of the same page that a real browser can never produce. Using the iframe's own toString also defeats a patched toString in the parent. The check reports N/A (unscored) if contentWindow is unavailable or the probe throws.

How to resolve it

The listed fields are being overridden in the top realm only. Move the values into the engine so every realm is born with them, rather than patching window.navigator after load. Frameworks that inject an init script per frame can narrow the gap, but a synchronously created about:blank iframe can be read before any per-frame script has run, and the wrapper artifacts this check does stringify — permissions.query and canvas.toDataURL, read through the iframe's own Function.prototype.toString — remain.

Cross-realm coherence: why a worker must agree with the page
worker-navigator-coherenceWorker and main thread report identical platform, hardwareConcurrency, webdriver and UA.

What a detector infers

A Blob-URL Worker is spawned and reports its own navigator.platform, navigator.hardwareConcurrency, navigator.webdriver and navigator.userAgent; the check compares each against the main thread (webdriver via Boolean coercion, userAgent only when the worker returned one). A WorkerGlobalScope is a separate execution context with a WorkerNavigator supplied straight from the engine — page scripts never run there, so anything patched on the document's navigator simply does not exist in the worker. A detector reading both sides sees one browser reporting two different identities for the same immutable facts, which is not a state a genuine browser can occupy; the divergence localises the spoof to the main thread. The probe is memoized and shares one worker with the WebGL worker check; if it times out (4s) or errors, the check reports N/A and is not scored.

How to resolve it

Apply identity values at the engine level so WorkerNavigator is constructed with the same values as Navigator. If you must stay in userland, the same override has to be installed in every worker scope too — which is unreliable for Blob and module workers and still leaves the descriptor and toString artifacts the other checks read.

How bot detection works — and why Clearcote patches the engine
page-world-wrappersAn ordinary browser with no extensions leaves fetch, Request and document's methods untouched; with extensions installed, entries here are normal.

What a detector infers

This probe stringifies window.fetch and window.Request and asks whether each still renders as native code, then checks whether document carries its own createElement, querySelector or addEventListener descriptors shadowing the prototype chain (createElement and querySelector resolve via Document.prototype, addEventListener via EventTarget.prototype). These are the page-world surfaces ordinary browser extensions legitimately wrap — ad blockers, wallets, userscript managers and AI helpers all hook fetch and shadow document methods — so a hit here does not by itself distinguish an instrumented page from a normal user's browser. That is exactly why this check is severity 'info': it is contextual, never scored, and its pass value is either true (nothing wrapped) or null (something wrapped), never false. It is reported because an operator running a clean automation profile with no extensions should expect an empty list, and because a JS-injection stealth layer surfaces in the same place.

How to resolve it

Nothing to fix if you are running extensions — this is informational and does not affect the score. On a clean automation profile the list should be empty; if it isn't, something in your stack (or a preload/init script) is wrapping fetch, Request or shadowing document methods in the page world.

How browser stealth is measured
navigator-proxy-honeypotnavigator.rml and the other invented names all read back as undefined, and `'rml' in navigator` is false.

What a detector infers

This reads five navigator property names that exist on no browser — rml, ln, rnd, webdriverPatched and __clearcoteProbe — and additionally evaluates `'rml' in navigator`. The correct answer on every real engine is undefined for all five, and false for the `in` test, so the check has zero false-positive surface by construction: it fails only if something answers a question no browser can answer. A navigator backed by a Proxy with a catch-all get (or has) trap responds to arbitrary keys and thereby reports its own existence, independently of how plausible any real property's value is. The name rml is not arbitrary: it appears in a decompiled commercial detector VM among genuine navigator reads, which is why it is included verbatim.

How to resolve it

Never back navigator with a Proxy that answers arbitrary keys. Spoof individual properties by overriding only the specific accessors you intend to change — ideally at the engine level — so unknown keys fall through to undefined and `in` reports them absent, as on a stock browser.

How anti-bot systems detect automation
native-brand-checksBoth getParameter and canPlayType reject a bare-prototype receiver with a TypeError.

What a detector infers

Two native methods are invoked with their own bare prototype as the receiver: WebGLRenderingContext.prototype.getParameter.call(WebGLRenderingContext.prototype, 37445) and HTMLMediaElement.prototype.canPlayType.call(HTMLMediaElement.prototype, 'video/mp4'). The C++ implementations perform a brand check on the receiver — a bare prototype is not a real context or media element — so both must throw a TypeError. The check fails if either does not throw at all (the receiver check is gone, and the call returns a spoofed value such as a fake vendor string), or if it throws something other than a TypeError. A JS or Proxy replacement has no brand check to inherit, so this catches hooks that stringify convincingly and walk past toString-based probes. Only the error type is asserted, never the message wording, which varies by engine.

How to resolve it

Don't replace these methods with JS functions or Proxies. Faking Function.prototype.toString is not sufficient — the C++ receiver check is a hard invariant a JS replacement cannot reproduce. Apply WebGL and codec spoofing inside the engine so the native brand check stays intact.

Why toString reveals a hooked function
iframe-srcdoc-placementA freshly created <iframe> has no own descriptors — srcdoc, src and contentWindow all resolve via HTMLIFrameElement.prototype.

What a detector infers

A fresh <iframe> is created with document.createElement and inspected for own property descriptors on srcdoc, src and contentWindow. On a stock engine these three resolve through HTMLIFrameElement.prototype, so a newly created element carries no own descriptors for them; the check fails if any per-element descriptor is present. This probes placement rather than value, and placement is the axis that engine-level spoofing preserves for free while JS interception cannot: controlling what loads into a fresh realm means installing accessors on the iframe's own srcdoc or src. That per-element machinery is precisely the countermeasure a stealth kit builds against cross-realm probes, and reading the srcdoc accessor's source text is a known detector behaviour. Severity is 'warn'; if the element cannot be created the check reports N/A.

How to resolve it

Stop installing per-element accessors on iframes to intercept what loads into new realms. If you need child realms to inherit a spoofed identity, apply the spoof at the engine level so it is present in every realm natively, leaving fresh iframe elements descriptor-free.

How a stealth browser is built and verified
shadow-dom-integrityattachShadow is name "attachShadow", length 1, native source in both realms; a closed root returns null from element.shadowRoot and an open root returns itself.

What a detector infers

The check reads Element.prototype.attachShadow four ways and asserts they agree: its name is "attachShadow", its length (declared parameter count) is 1, it stringifies as native code — measured both in this realm and through a fresh iframe's Function.prototype.toString — and its runtime behaviour is correct, meaning a shadow root created with {mode:"closed"} is not exposed through element.shadowRoot while a {mode:"open"} one is. These come from different layers: name, length and source are properties of the function object, while the closed/open visibility is enforced by the DOM implementation itself. A detector that finds them disagreeing learns the function was replaced with a JavaScript wrapper. This surface matters because the newest Kasada scripts added eleven probes that do exactly this — attachShadow.name, .toString(), .length and shadowRoot.closed/open, in both the window and an iframe — since some anti-detect builds wrap attachShadow to stop pages from fingerprinting through closed roots, and the wrapper's own name/length/source or its handling of the closed/open contract gives it away. The cross-realm read is what defeats a wrapper that hides its own toString: the iframe's untouched toString stringifies the main-realm function without asking the wrapper.

How to resolve it

Leave Element.prototype.attachShadow native. If a build wraps it to control shadow-DOM fingerprinting, that wrapper is visible through its name, length, source or its closed/open behaviour — do the spoofing at the engine level instead, where the function object and the DOM contract stay coherent.

Why does toString reveal a hooked function?
rng-integrityMath.random is native (name "random", length 0, native source in both realms) with full-entropy output; crypto.getRandomValues is native and produces varied bytes.

What a detector infers

The check verifies that Math.random is the engine's native generator and that its output is high-entropy. It confirms the function's name is "random", its length is 0, and it stringifies as native code both in this realm and through a fresh iframe's toString; then it draws 2048 samples and asserts they all fall in [0,1), that at least 90% are distinct, and that their mean sits in a loose band around 0.5. It applies the same native check to crypto.getRandomValues and confirms 256 generated bytes are varied. The motivation is that some anti-detect kits reseed or wrap Math.random to make a run reproducible — for deterministic canvas or WebGL noise, or repeatable timing — and that replacement is what the probe detects. The primary signal is deliberately the native and cross-realm toString rather than the statistics: a well-built PRNG can pass distribution sanity, so the tampering shows up first as a non-native source or a wrapper the iframe's clean toString unmasks. The statistical bands are intentionally loose so a genuine engine PRNG is never convicted. Kasada samples both Math.random (its floor·4096, uint16 and sum probes) and crypto.getRandomValues.

How to resolve it

Do not reseed or wrap Math.random for reproducible runs. The wrapper is visible through its source and the cross-realm toString, and a seeded generator collapses output entropy. If determinism is needed for testing, seed a separate PRNG rather than overwriting the global.

Why does toString reveal a hooked function?
window-globals-vs-realmEvery global present in a fresh realm is also present here, and the shared names appear in the same relative order. Extra globals on the page are normal and ignored.

What a detector infers

The audit builds a fresh same-origin iframe and enumerates the own properties of both global objects, then asserts two things a browser cannot violate on its own. First, subset: every platform global the pristine realm has must also exist here, because both realms are built from the same blueprint by the same browser. Additions are ignored outright — a real page has plenty, and so do extensions and wallets — but nothing legitimate REMOVES a platform global. Second, order — but on V8 only. V8 emits string-keyed own properties in insertion order and installs a realm's globals in a fixed order at setup, so the names shared by both realms must appear in the same relative sequence. SpiderMonkey does not work this way at all: Gecko resolves standard classes lazily, and the global's enumerate hook reports the still-unresolved names first and appends the already-resolved ones after, so a global sorts to the end merely by having been touched — order there reflects each realm's resolution history rather than a blueprint. Measured on stock Firefox 152 with a fresh profile and a bare page: 980 of 982 shared names out of position. Asserting order on every engine would not catch one extra spoof; it would invent a rule V8 happens to satisfy and then fail every Firefox, Tor Browser and LibreWolf user with a critical contradiction. The subset half stays unconditional, because it is engine-neutral and measured clean on every Gecko configuration. That second assertion is the interesting one, because it defeats the standard manoeuvre for hiding a global: `delete window.foo` followed by re-adding a convincing replacement. The replacement is appended, so it lands at the END of the property order, and the order itself becomes the evidence — no amount of making the VALUE look right can move it back to where the browser originally installed it. This is the largest single family in Kasada's deployed script: 44 of its 427 probes enumerate the global in both realms and sample length, five indices, and five slice indices from each. They can diff those samples against a server-side model of stock Chrome; this check has no baseline, so it asserts only what one realm can prove about the other. Integer-like keys are filtered first — those are the indexed frame accessors, and a page with subframes legitimately has more of them than a bare iframe.

How to resolve it

Do not delete or reassign globals to hide them — a fresh realm is built from the same blueprint as this one, so an absence here is visible against it, and re-adding a global puts it at the end of the property order where it never belonged. If a value must differ, change it at the browser layer, below JavaScript, so both realms derive it from one source rather than one realm being edited after the fact.

How automation gets caught, layer by layer
contentwindow-realm-identitycontentWindow is a distinct realm with its own intrinsics, its top and parent are this window, it is strictly identical to the frame the indexed getter yields, and it names this origin as its ancestor.

What a detector infers

This is the check that guards the others. A large part of this audit — the global property diff, all six capability surfaces, the brand hints, the shadow DOM probe, the iframe scalar comparison — reaches through iframe.contentWindow and trusts that what comes back is a genuinely separate realm the browser built from its own binary. That trust has exactly one countermeasure, and it defeats every one of those checks simultaneously: stub contentWindow to return the MAIN window. Every cross-realm comparison then compares the page against itself, agrees trivially, and the whole suite reports green. So contentWindow has to be verified — and it cannot be verified using contentWindow. The way out is the WindowProxy indexed getter. window[0], window[1] and so on are backed by C++ on the Window object itself, are not configurable from JavaScript, and are the one handle on a child frame that a page-world patch cannot intercept. The check fetches the frame both ways and requires strict identity between them; it also requires the realm to own distinct intrinsics (its own Object, Array and navigator, because a realm that shares ours is not a second realm), to report this window as its top and parent, to name our iframe element as its frameElement, and to name this page's origin in its own ancestorOrigins. Every index is scanned rather than assuming zero, because the page has other frames — a chat widget, an ad slot — and this audit creates several of its own, so the position is not fixed; only the identity is. Note what is deliberately never compared here: values. Brave's farbling, Firefox's resistFingerprinting and Safari's ITP all rewrite values and none of them touches realm identity, which is what makes this check structurally immune to them.

How to resolve it

Do not stub or wrap HTMLIFrameElement.prototype.contentWindow. Returning the parent window from it is the single edit that would make every cross-realm comparison on this page agree at once — which is precisely why the frame is also fetched through window[i], a C++ getter that page script cannot shadow, and the two objects are required to be the same one.

How automation gets caught, layer by layer

Render & GPU

webgl-not-softwareA hardware renderer string. The check marks SwiftShader / llvmpipe / "Software" as a tell, but at info severity it is excluded from the score — and CPU rasterisation is legitimate on VDI/RDP, with hardware acceleration off, on blocklisted GPUs and on driverless Linux.

What a detector infers

The audit reads UNMASKED_RENDERER through the WEBGL_debug_renderer_info extension and matches it against SwiftShader, llvmpipe and Software. Those names mean the page is being rasterised on the CPU rather than a GPU, which is the default in many headless and container setups — but equally in VDI/RDP sessions, with hardware acceleration switched off, on blocklisted GPUs, and on driverless Linux. Because CPU rasterisation is a perfectly legitimate state on its own, this check is info severity and is excluded from the score entirely. What makes a renderer string evidence is not its value but its agreement with other surfaces — a detector infers far more from a software renderer that contradicts a discrete-GPU string reported elsewhere than from a software renderer alone.

How to resolve it

Nothing to fix if the software renderer is real; it is a coherent state that many ordinary users report, and this check does not affect the score. Only act if it contradicts a GPU claimed on another surface — then make the two agree, either by giving the browser a real GPU-backed render path (canvas bridge) or by dropping the discrete-GPU string.

How is a headless browser detected?
webgl-webgpu-vendorBoth surfaces name the same GPU vendor; one string contains the other.

What a detector infers

Two independent graphics stacks in the same browser are backed by the same physical adapter: this check lowercases the WebGL UNMASKED_VENDOR string and the vendor from navigator.gpu.requestAdapter()'s adapter info, and passes when either contains the other (so "nvidia" and "nvidia corporation" reconcile). A modification that hooks WebGLRenderingContext.getParameter changes only the WebGL answer; navigator.gpu is a different API surface entirely and keeps reporting the real adapter. A detector reading both sees one machine claiming two different GPU vendors, which no real hardware configuration produces. The check reports N/A when either surface is missing (no WebGPU support, no adapter returned, or a masked WebGL vendor) — with only one reading there is no cross-surface contradiction to observe.

How to resolve it

Establish GPU identity at a level both stacks read from — an engine-level control or a bridge to a real GPU — rather than hooking WebGL's getParameter. If your setup cannot cover WebGPU, leave the WebGL strings real too; a matched pair of honest strings is coherent, a mismatched pair is not.

Anatomy of a browser fingerprint: every signal, and why they must agree
webgl-param-tableNon-empty unmasked vendor and renderer, plus 12 or more parameters returning values.

What a detector infers

Beyond the two headline strings, a WebGL context answers getParameter for dozens of driver-specific limits. The audit queries 21 of them — MAX_TEXTURE_SIZE, MAX_VIEWPORT_DIMS, MAX_VERTEX_UNIFORM_VECTORS, ALIASED_LINE_WIDTH_RANGE, the RED/GREEN/BLUE/ALPHA/DEPTH/STENCIL bit depths, MAX_TEXTURE_MAX_ANISOTROPY_EXT and others — hashes the collected values, and passes when at least 12 of them returned a value and the context is not masked: WEBGL_debug_renderer_info must be present, UNMASKED_VENDOR and UNMASKED_RENDERER must both be non-empty, and the renderer must not read "Masked" or "Not supported". Only a missing WebGL context reports N/A — a stripped debug extension is a fail, not an abstention. Note the scope: it verifies the table is present and unmasked, not that the limits match the named GPU. That second step is the detector's: they hash the whole table, so a rewritten renderer string can be checked against limits that belong to a different GPU — which is why exposing an honest table only helps if the strings describe it.

How to resolve it

Expose a real GPU's parameter table by giving the browser a genuine GPU-backed context (canvas bridge). Don't strip WEBGL_debug_renderer_info or blank out vendor/renderer — the absence is itself distinguishing. If you do set the strings, set them to a GPU whose limits actually match the table you are exposing.

What is WebGL fingerprinting?
webgl-worker-vs-mainWorker and main thread name the identical vendor and identical renderer.

What a detector infers

The audit reads UNMASKED_VENDOR and UNMASKED_RENDERER on the main thread, then spawns a dedicated Worker that builds its own WebGL context on an OffscreenCanvas and reads the same two strings, requiring exact equality of both. A Worker is a separate JavaScript realm with its own globals: a patch applied to the page's WebGLRenderingContext.prototype.getParameter never executes there, so the Worker reports the real GPU while the main thread reports the fictional one. A detector treats that as a high-confidence signal because no physical machine presents two different GPUs to two scopes of the same page — the disagreement can only come from a modification that covers one scope. N/A if the Worker probe errors or times out (4s), or if either scope has no debug-renderer-info to read.

How to resolve it

Move GPU identity below JavaScript — an engine-level control or a canvas bridge — so every realm reads the same value from one source of truth. Chasing this by also injecting into every Worker is fragile: each new realm (Workers, nested Workers, iframes) is another place the patch must reach.

Why must a worker report the same values as the page?
webgl-engine-vendorMasked VENDOR reads "WebKit" whenever the UA claims Chrome or AppleWebKit.

What a detector infers

This check reads the plain gl.getParameter(gl.VENDOR) — the masked value, not the debug extension's unmasked GPU string. That constant does not describe the GPU at all; it labels the engine that built the GL context. Chromium reports "WebKit" and Gecko reports "Mozilla", so a Gecko-based browser presenting a Chrome user-agent still answers "Mozilla" here unless it separately rewrote this specific constant. The check only runs when the UA claims Chrome or AppleWebKit (otherwise N/A, as is a missing WebGL context), and then simply asserts the vendor matches /WebKit/i. The inference is direct: the claimed engine and the engine that actually created the context contradict each other, and the engine cannot be wrong about itself.

How to resolve it

Present a user-agent that describes the engine actually running. If a Chrome identity is required, run a Chromium-based browser; a Gecko engine behind a Chrome UA contradicts itself here and at several other engine-level constants, and papering over each one individually does not scale.

How automation gets caught, layer by layer
webgl1-webgl2-coherenceWebGL1 and WebGL2 return byte-identical UNMASKED vendor and renderer strings (or one context is unavailable, which reads N/A).

What a detector infers

The check creates a WebGL1 context and a WebGL2 context and reads UNMASKED_VENDOR_WEBGL / UNMASKED_RENDERER_WEBGL from each through the WEBGL_debug_renderer_info extension, then asserts the two contexts name the same GPU. The reason this catches spoofs is structural: in Chromium a WebGL1 context is a WebGLRenderingContext and a WebGL2 context is a WebGL2RenderingContext, and getParameter lives on two distinct prototypes. A stealth kit that hooks getParameter to rewrite the vendor/renderer often patches only one of those prototypes — usually WebGLRenderingContext — leaving the WebGL2 context reporting the real hardware. A detector reading both and finding them disagree learns the GPU string was rewritten at the JavaScript surface rather than being the true adapter, because a real machine's two context types are backed by the same driver and cannot disagree. Kasada reads webgl1_unmasked_vendor/renderer and webgl2_unmasked_vendor/renderer as separate probes for precisely this diff. The check reports N/A rather than failing when either context or the debug-renderer extension is unavailable, so a genuinely WebGL2-less or extension-stripped environment is never convicted.

How to resolve it

Spoof both WebGL context types or neither. A getParameter override must cover WebGLRenderingContext and WebGL2RenderingContext identically; better, set the GPU identity at the engine/driver level so both contexts derive from the same source and no per-prototype hook is needed.

What is WebGL fingerprinting?
canvas-tamperA pixel filled pure black reads back pure black.

What a detector infers

The probe fills a single pixel with pure black on a 1x1 canvas and reads it straight back with getImageData. An untouched pipeline returns exactly (0,0,0); a noise-injecting farble perturbs the value and it comes back non-black. This is a zero-baseline oracle — the expected answer is known a priori, so any site can run it in a few lines and needs no reference corpus. It distinguishes two very different properties: "this canvas output is unique" (ordinary, every machine differs) versus "this canvas output has been modified" (a deviation from the platform's defined behaviour). If navigator.brave is present and the pixel is perturbed, the check returns N/A and is not scored: Brave ships farbling as a stock privacy feature, so there the noise identifies the browser rather than indicating a lie.

How to resolve it

Where canvas output is scored, turn canvas noise off (fingerprint_noise=false / --disable-fingerprint-noise) or route readback through a real GPU with the canvas bridge, so the output is genuinely produced rather than perturbed after the fact. Randomised canvas output resolves a uniqueness problem while creating a coherence one.

What is canvas fingerprinting?
measuretext-gridEvery measured width is an exact multiple of 1/512 px.

What a detector infers

Chrome's 2D text metrics are quantised: advance widths are computed in fixed-point and land on a dyadic 1/512-pixel grid. The audit measures five strings at 12px Arial — "A", runs of W, m and i, and a mixed alphanumeric string — and asserts that width * 512 is an integer within 1e-6 for each. The mechanism is about the shape of the number, not its value: multiplying a width by a farble scale factor yields a result that is still a plausible-looking float but no longer sits on the grid, so post-processing is visible even when the metric itself looks reasonable. A detector needs no baseline for this — the grid is a property of the engine, checkable from the returned number alone. N/A only if measurement failed outright.

How to resolve it

Leave text-metric farbling off (the light_stealth preset does). If text metrics must vary per site, the variation has to happen inside the text/layout engine so results remain quantised on the grid; a multiplicative scale applied to the returned number is detectable here regardless of how small the factor is.

What is font fingerprinting?
worker-vs-mainAll five widths identical between the main thread and the Worker.

What a detector infers

The same five strings are measured at 12px Arial twice: once on a main-thread 2D canvas, and once inside a dedicated Worker on an OffscreenCanvas, compared to a tolerance of 1e-9. Text shaping runs the same engine code in both scopes, so a real browser returns bit-identical widths. Modifications applied only to the page's CanvasRenderingContext2D — the common case, since that is the realm injected scripts land in — never reach the Worker, so the two sets diverge. A detector reads that divergence as proof of scope-limited modification rather than of any particular hardware: the two answers cannot both be what the engine natively computes. N/A if the Worker fails to construct or does not answer within 5s.

How to resolve it

Apply any canvas or text-metric modification at a level both scopes share (engine-level), or apply none at all. Patching only the document's canvas prototype guarantees this divergence, and adding a second patch inside Workers only moves the problem to the next realm a detector opens.

Cross-realm coherence: why a worker must agree with the page
bcr-vs-rangeBoth APIs return the same left value, and both sit on the 1/512 grid.

What a detector infers

Two independent APIs are pointed at the same laid-out text: an absolutely-positioned div containing "WWWWWiiiii" is measured with element.getBoundingClientRect().left, and a Range selecting the same node contents is measured with getClientRects()[0].left. Both read out of the same layout tree, so on an unmodified browser they agree exactly and both land on the 1/512 grid — the check passes only when the two lefts agree within 1e-6 AND both are on-grid. That double condition catches two distinct situations: a hook that perturbs getBoundingClientRect (a common way to fuzz element geometry) rarely also covers Range client rects, so the readings contradict each other; and a scaled value fails the grid test even if both paths were covered. N/A if the probe could not produce both rects.

How to resolve it

Don't perturb rect geometry from JavaScript. If element geometry must vary, it has to vary in layout itself so every rect API reads from the same tree and stays quantised — otherwise the two readings of one element disagree, which no real rendering produces.

What is DOMRect fingerprinting?
layout-box-arithmeticA content-box div of width 29px with 17px of left padding and a 3px left border measures clientWidth 46 and offsetWidth 49, exactly.

What a detector infers

clientWidth and offsetWidth are not opinions the browser offers — CSSOM defines them as arithmetic over values the same element hands you through getComputedStyle. Under content-box with no scrollbar, clientWidth is exactly width plus padding, and offsetWidth adds the borders. So a browser can be made to contradict itself using nothing but its own two APIs. The probe is DataDome's own, read opcode by opcode out of its bytecode VM: it creates a div, sets box-sizing to content-box, width to 29px and padding-left to 17px, appends it, reads one value — clientWidth — and removes it. 29 + 17 = 46 is the only answer that closes. We add a 3px border so offsetWidth must independently be 49, which costs nothing and doubles the arithmetic. Two details are worth knowing. DataDome parks its probe at left:-9999px; we park it at left:0 instead, because Blink snaps clientWidth against the box's own x position and at browser page zoom — which getComputedStyle cannot see — a fractional x can legitimately snap the answer to 45 or 47. And their choice of visibility:hidden over display:none is deliberate and correct: a display:none element generates no layout box at all and measures 0, telling you nothing. What this catches is tooling that stubs element metrics to blunt font- and element-size fingerprinting: it patches the getters, not the layout engine, and the sum stops closing. It is also the cheapest possible proof that layout actually ran.

How to resolve it

Leave element metrics native. clientWidth and offsetWidth are computed from the box the layout engine actually built, so overriding them leaves them disagreeing with the computed style of the very same element — a contradiction visible from inside the page with no baseline at all. If different font metrics are the goal, change the fonts rather than the rulers.

What is font fingerprinting?
webgpu-sync-surface-vs-realmEvery realm that exposes navigator.gpu reports the same preferred canvas format and the same SET of WGSL language features. The order they are listed in is not asserted — it legitimately varies by realm.

What a detector infers

WebGPU's cheapest fingerprint is not the GPU at all. navigator.gpu.getPreferredCanvasFormat() and navigator.gpu.wgslLanguageFeatures are compiled-in build constants — no adapter, no device, no await, no GPU involved — which is why DataDome collects them in the first handful of fields in its payload and still gets them on hardware where the adapter path fails entirely. A build constant cannot vary by realm: the worker and the iframe are running the same binary as the page. So this check reads both in every realm it can reach and requires them to agree, and because nothing here calls requestAdapter, a null or blocklisted adapter cannot influence the answer. The presence/absence case is deliberately reported rather than counted, and that distinction is the interesting part: Brave with Shields disabling WebGPU, and privacy extensions whose content script deletes navigator.gpu, both run in the MAIN world, while a worker built from a blob URL keeps the native object — so a realm that has the API next to one that does not is the signature of a page-world blocker, which is an honest configuration. Two realms both exposing a working API and disagreeing about its VALUE is a different animal, and that is what earns a contradiction: the preferred format is a two-value enum that is worthless to farble, and nothing shipping farbles the WGSL language feature set. Only MEMBERSHIP is asserted, and that limit was set by a measurement rather than by taste: on a stock Chrome 150 the page and a fresh iframe list these features in the same order, but a dedicated worker lists the same ten in a different one. wgslLanguageFeatures is a setlike and nothing specifies its iteration order across realms, so an order assertion describes how each realm happened to populate its registry rather than anything about the build — it would have failed an ordinary browser on the first run. This check also carries a reading lesson. The field DataDome ships is snake_case, which makes it wgslLanguageFeatures — the WGSL language extension set — and not the kebab-case GPUAdapter.features. Our own field map had that mislabelled, and a check built on the wrong one would have targeted an API DataDome cannot even reach.

How to resolve it

These two values are decided when the browser is compiled. A worker and an iframe run the same binary as the page, so a realm answering differently means the answer is being generated rather than reported — and whatever is generating it reaches only the page world. This is also why they are the cheapest WebGPU signals for a detector to collect and the hardest to fake coherently.

What is WebGL fingerprinting?
webgl-limits-vs-driverThe largest uniform array that compiles equals MAX_VERTEX_UNIFORM_VECTORS, and the driver's own per-format sample list reaches MAX_SAMPLES.

What a detector infers

Every other WebGL check on this page reads what getParameter SAYS. This one asks the driver to HONOUR it, and that difference is the whole point. getParameter is a lookup a page can rewrite; compiling a shader is work the GPU either does or refuses, and allocating a multisampled renderbuffer is a capability the backend either has or lacks. So the second source here is the hardware itself — the same shape as the Math.tanh OS oracle, a fact produced below JavaScript where no override reaches. The check does two things. It binary-searches the largest uniform array the GPU will actually compile and compares that against MAX_VERTEX_UNIFORM_VECTORS — about twelve shader compiles for a 4096 ceiling. And it compares MAX_SAMPLES against getInternalformatParameter(RENDERBUFFER, RGBA8, SAMPLES), which goes to the driver at call time and returns only the sample counts the real backend supports. A rewritten limit shows up immediately: the number says 4095 and the compiler refuses at 1025. Only the SHORTFALL direction is a finding — a driver that compiles more than it advertises is being conservative, which is legal and not a lie about anything. Note what makes this check unusually durable: there is no table of real limits to maintain, no list of GPUs, nothing to go stale. The only question it asks is whether this machine can do the thing it just claimed it could do, and the machine answers for itself.

How to resolve it

These limits are not metadata, they are promises the GPU has to keep. Rewriting getParameter changes the number a page reads while the driver goes on refusing to compile the shader or allocate the buffer, so the claim contradicts the hardware being asked to deliver it. If a different GPU class must be presented, present it from a build whose driver actually has those limits.

What is WebGL fingerprinting?
shader-backend-vs-rendererA renderer string naming Direct3D comes with an HLSL translation; the two describe the same backend because one of them is produced by it.

What a detector infers

The UNMASKED_RENDERER_WEBGL string is a label — a piece of text a page can rewrite to name any GPU and any graphics backend. ANGLE's translated shader source is not a label: it is the compiler's own output, produced by whichever backend is actually running, and handed back through WEBGL_debug_shaders. Those are two accounts of one fact, and one of them is generated by the machinery being described. The dialects are unambiguous where it matters. ANGLE's Direct3D backend compiles GLSL down to HLSL and the translation opens with an HLSL preamble; its GL and Vulkan backends emit versioned GLSL instead. So a renderer string containing Direct3D, on a machine whose translator emits '#version 450', has named a backend the machine is not running — the classic shape of a Linux container presenting a Windows GPU. Only the Direct3D direction is asserted, deliberately: D3D always means HLSL, whereas the GL, Vulkan and Metal backends all produce GLSL-family output and separating them from the translation alone would be guesswork rather than evidence. The check declines when WEBGL_debug_shaders is unavailable — Firefox does not expose it — and when the renderer is masked, because then there is no claim to check anything against.

How to resolve it

The renderer string is a name tag; the translated shader is what the compiler actually did. Rewriting the string to say Direct3D while the machine runs Mesa or Vulkan leaves the translation speaking GLSL, and no page-world override reaches the shader translator — it runs below the JavaScript that would patch it. Present the backend the machine really has.

What is WebGL fingerprinting?
webgl-extensions-vs-driverEvery name in getSupportedExtensions() returns a non-null object from getExtension — the list and the driver agree because they are the same driver.

What a detector infers

getSupportedExtensions() returns a list a page can rewrite; getExtension(name) either hands back a real extension object or null, and that answer is the driver. So the list is a claim and each getExtension is the driver honouring or refusing it. A context that advertises an extension it cannot instantiate is contradicting its own capability report — the shape a spoof produces when it pads the extension list to resemble a target GPU without the backend to support it. Direction is chosen carefully: a short list is fine, because privacy builds legitimately trim it and Brave in its strict mode returns few or none, and a lost or absent context is quiet. The invariant fires only on an advertised-but-unbacked entry, which no honest driver produces. Like the GL-limits check, it needs no table of real extensions per GPU — it only asks whether the context can do the thing it just said it could.

How to resolve it

getSupportedExtensions and getExtension read one driver and cannot disagree in a real browser. Padding the list to look like another GPU leaves getExtension returning null for the entries the backend lacks. Present a GPU whose real driver supports what you advertise.

What is WebGL fingerprinting?
webgl-draw-path-invarianceA fixed-function clear to red and a shader emitting red read back as byte-for-byte identical pixels.

What a detector infers

Opaque red can be produced two ways: the fixed-function clear path, and a fragment shader emitting vec4(1,0,0,1). Both end in the same framebuffer and are read back through the same readPixels call, so on real hardware they are byte-for-byte identical. That identity is the invariant. A spoof that farbles WebGL readback to fake a GPU perturbs the value it reads without matching what a second, independent render path produces, and a software rasteriser can round the float-to-UNORM8 conversion differently on the clear fast-path than on the shader path — either way the two reads diverge. The clear path is used as a gate, not merely a step: if clearing to (1,0,0,1) does not read back as exactly opaque red, this backend colourspace or conversion is unfamiliar and the check goes quiet rather than guessing. That gate is also what makes it robust to the browsers that would otherwise burn it. Brave farbles WebGL readback and Firefox with resistFingerprinting blanks it, but both do so as a deterministic function of origin and pixel index, so identical input buffers read through identical calls receive identical treatment and the comparison still holds. Only a genuine divergence between two paths into one framebuffer fails it.

How to resolve it

Both paths write the same colour to the same framebuffer and are read through the same call, so they cannot differ on real hardware. Perturbing WebGL readback to fake a GPU, or a software rasteriser rounding the two paths differently, is what separates them. Leave the render path native, or present a GPU whose driver produces a consistent framebuffer.

What is canvas fingerprinting?
shader-mediump-claim-vs-arithmeticA mediump format reporting fp32-class precision computes ((u+1/8192)-u)*8192 as 1.0 (red 255) — the arithmetic delivers the precision the format claims.

What a detector infers

getShaderPrecisionFormat is a claim about float precision; a shader that computes a value only fp32 can represent is the GPU keeping or breaking that claim. The probe evaluates ((u + 1/8192) - u) * 8192 at mediump precision with u set to 1.0: in true fp32 this is 1.0 and the red channel reads 255, while in fp16 the tiny addend is lost and the result collapses toward zero. So a context that advertises fp32-class mediump — a reported precision of at least 23 bits — but computes at lower precision is contradicting its own precision report, the shape a software rasteriser like SwiftShader or llvmpipe shows under docker when its format is spoofed to resemble a real GPU. The check is gated on the claim itself: real mobile GPUs such as Adreno and Mali honestly report fp16-class mediump and genuinely compute in fp16, so claim matches delivery, the check never runs on them, and it cannot false-positive on mobile. It only speaks when a browser has promised fp32 precision, and then it holds the arithmetic to that promise.

How to resolve it

getShaderPrecisionFormat and the shader compiler read the same GPU, so an fp32 claim must be honoured by fp32 arithmetic. A software rasteriser reporting a spoofed high-precision format while computing at another precision is the contradiction. Present the precision the hardware actually delivers.

What is WebGL fingerprinting?
audio-render-silence-integrityAn unconnected offline context and a gain-0 oscillator both render channel data that is exactly zero at every sample.

What a detector infers

The other audio checks compare two renders of a sound; this one renders silence and asserts it stayed silent, which catches a different kind of tamper. An audio stack that adds a per-sample noise floor to defeat fingerprinting cannot tell a quiet signal from no signal, so it leaks its noise into a buffer that must be bit-exact zero. Two silences are rendered: an OfflineAudioContext with nothing connected to its destination, and an oscillator routed through a gain node pinned to zero. A real Web Audio graph produces exact zero in both. Negative zero is treated as zero, and the gain node is both constructed with a gain of 0 and pinned with setValueAtTime, so no de-zippering ramp exists at the head of the buffer — older Chrome smoothed a gain change to zero over about 20 milliseconds, and without this precaution that onset would read as a false non-zero on a genuine old browser. The signal is strong because silence is the one output a perturbing stack cannot hide inside: there is nothing for the noise to blend into.

How to resolve it

Do not inject per-sample noise into the Web Audio output. A graph that cannot render exact silence reveals its perturbation on every buffer, including the ones carrying no sound. Leave the audio stack native, or perturb nothing.

What is AudioContext fingerprinting?
eme-clearkey-baselineOn a Chromium build, requestMediaKeySystemAccess either is absent (no EME at all) or resolves org.w3.clearkey — the baseline the spec makes mandatory for any browser that implements EME.

What a detector infers

Encrypted Media Extensions is the DRM entry point, and it has one part that cannot be missing. The spec designates org.w3.clearkey as the mandatory baseline key system: it is the only one with no proprietary content decryption module behind it, so any browser that implements EME at all must be able to satisfy it. That single fact turns a notoriously table-shaped surface into a clean invariant. The tempting version of this check — does a Chrome-identified browser support Widevine — is a rotting table and a false-positive machine: Widevine ships in branded Chrome but not in a bare Chromium build, Firefox downloads its CDM after install, enterprise policy can remove it, and Linux is simply inconsistent, all of them honest configurations. So Widevine is collected for the read-out and never scored. What IS scored is the narrow question the spec answers for us: a browser exposing navigator.requestMediaKeySystemAccess has declared that it implements EME, and an EME implementation that then rejects ClearKey is stubbed rather than real — the EME entry point was left exposed while the media pipeline underneath was compiled out, which is the shape a headless or docker build shows. The check is gated on the measured engine, not the user agent: on V8 the ClearKey guarantee holds, so a rejection is a genuine contradiction, while off V8 it declines outright because Safari's ClearKey history is uneven and asserting it there would be inventing a rule rather than reading one. It also declines when EME is absent entirely, since a build with no media-keys support is a legitimate configuration and not a stub.

How to resolve it

ClearKey needs no proprietary CDM, so a real EME stack cannot fail it. A rejection means the media pipeline was compiled out while the EME entry point was left exposed — the classic headless/docker shape. Run a build with the real media stack, or one that does not advertise EME at all; do not stub requestMediaKeySystemAccess.

What is a headless browser?

Capability surfaces

css-surface-vs-realmAll fifty-odd answers identical in both realms — the surface is a property of the binary, and both realms run the same binary.

What a detector infers

Which CSS properties, values and selectors an engine understands is decided when the browser is compiled. The check asks CSS.supports() around fifty questions — modern layout (grid, container queries, :has()), engine-owned prefixes (-webkit-app-region, -moz-orient), and recent additions (anchor positioning, view transitions, field-sizing) — then asks the identical fifty inside a fresh same-origin iframe and compares every answer. There is no legitimate way for one realm of a browser to support a CSS property another realm does not: both are served by the same compiled style engine. So a disagreement does not mean the browser is unusual, it means the answers are being generated rather than reported, by something that reaches the page world but not a realm the browser builds fresh from its own binary. Note what this deliberately does not do: it never compares the feature set against a table of which Chrome version shipped what. Such tables are exactly the kind of hand-maintained corpus that goes stale and then starts accusing ordinary browsers of being fake — a failure this audit has already made once. Comparing a realm against another realm cannot rot, because both sides are measured live. This is the biggest single family in Kasada's deployed script after property enumeration: roughly forty-nine of its four hundred and twenty-seven probes are individual CSS.supports questions, asked in both realms.

How to resolve it

Do not rewrite CSS.supports or the style surface from page script. A fresh realm is constructed straight from the browser binary and will keep answering honestly, so patching the page world converts a capability into a contradiction. If a different feature set is genuinely required, it has to come from a different build.

Anatomy of a browser fingerprint: every signal, and why they must agree
media-surface-vs-realmAll twenty-five device-level features identical in both realms. Viewport-scoped features (orientation, colour scheme, display mode) are shown but deliberately not asserted, because they legitimately differ.

What a detector infers

Media features are answered by the layout engine rather than by the JavaScript properties that describe the same machine, which is what makes them worth asking twice. This check takes twenty-five DEVICE-level features — the pointer and hover capabilities of the attached input hardware, colour gamut, dynamic range, monochrome and colour-index depth, forced-colours and inverted-colours accessibility state, update cadence — and evaluates them in both the page and a fresh iframe. Device capability belongs to the machine, not to a document, so every realm of one browser must answer identically. The split matters as much as the check. Media features answer questions about three different things, and only device capability is realm-invariant: `orientation` follows the viewport's aspect ratio, so a 1x1 probe iframe is legitimately a different orientation than a landscape window, and `prefers-color-scheme` follows the document's own color-scheme declaration, which about:blank does not inherit from the page. Both were measured differing on a completely stock Chrome. Asserting on them would have failed every real browser, so they are collected for the read-out and never compared — the check asserts only the twenty-five that were verified to agree.

How to resolve it

Nothing to fix if the answers agree. If they disagree, something is answering media queries for the page world while the layout engine keeps answering honestly one realm away — the durable fix is to give the browser the real capability rather than to rewrite its answer.

Coherence over camouflage: why a plausible identity beats a hidden one
codec-surface-vs-realmEvery codec answer identical across realms, including the empty answer for the deliberately bogus MIME type.

What a detector infers

canPlayType answers come from the media stack compiled into the browser, and the shape of that matrix is genuinely informative: branded Chrome ships proprietary decoders (H.264, AAC) that a bare Chromium build simply does not have, so the pattern of probably/maybe/unsupported across forty-odd MIME strings describes the binary. This check asks the whole matrix — WAV, MP3, AAC, FLAC, Opus, Vorbis, Theora, VP8, VP9, AV1, H.264 at several profiles, HEVC, MP2T, 3GPP, Matroska, QuickTime, and a deliberately bogus type — in both the page and a fresh realm, and requires every answer to match. A decoder cannot exist in one realm of a browser and not another. The bogus MIME string is included on purpose: a real media stack answers the empty string for a codec it has never heard of, and something synthesising answers has to decide what to say about a codec that does not exist. Kasada asks roughly forty-five canPlayType questions, which is more of its budget than it spends on WebGL.

How to resolve it

Codec support is a property of the build. If the matrix must look different — the usual reason is that a bare Chromium lacks the proprietary decoders branded Chrome has, which is itself visible — use a build that actually has them rather than rewriting canPlayType, which only holds in the realm you patched.

Anatomy of a browser fingerprint: every signal, and why they must agree
input-surface-vs-realmThe same set of types resolve natively in both realms; unsupported ones fall back to "text" identically in both.

What a detector infers

Assigning an unrecognised type to an <input> element does not throw — the element silently falls back to reporting type "text". That turns a one-line probe into a map of which input types the engine actually implements, and the check builds that map for twenty-two types in both the page and a fresh realm. Like the CSS and codec surfaces, this is decided at compile time: an engine that implements <input type=color> implements it in every realm it constructs. The fallback behaviour is the elegant part, because it means the probe reads the engine's real capability rather than any declared list — there is nothing to consult and nothing to lie to, only what the element does when told to be something it cannot be.

How to resolve it

Nothing to fix unless the realms disagree, which would mean element behaviour is being rewritten in the page world while the engine keeps behaving honestly a realm away.

Anatomy of a browser fingerprint: every signal, and why they must agree
emoji-surface-vs-realmIdentical advance widths in both realms — both are shaped by the same platform font through the same engine.

What a detector infers

Emoji do not render from the page's fonts but from a platform font supplied by the operating system — Segoe UI Emoji on Windows, Apple Color Emoji on macOS, Noto Color Emoji on Linux and Android. Their advance widths, measured through canvas measureText, therefore describe the OS's font stack rather than anything the browser reports about itself. This check measures a spread of emoji, including a ZWJ sequence and text-presentation symbols that stress the shaping path differently, and requires the widths to match in a fresh realm. What it deliberately does not do is compare those widths against a per-OS reference table. Such a table would let it name the operating system, which is tempting, but it would also rot with every font update and start accusing ordinary browsers — and there is already a better OS oracle on this page in Math.tanh, which reads the C library directly and cannot go stale. So this stays a realm comparison: severity warn, because font stacks are the one place where an extension or a userstyle can legitimately intervene.

How to resolve it

If the widths disagree across realms, text measurement is being intercepted in the page world only. Note that font metrics are a poor thing to fake: they must stay consistent with the platform you claim, with the fonts you claim to have installed, and with every other realm at once.

What is font fingerprinting?
animation-surface-vs-realmThe same API surface and the same computed timing values in both realms.

What a detector infers

The Web Animations API exposes a small, precisely specified surface: whether Element.animate exists, whether the timeline and KeyframeEffect constructors are present, and what a created animation reports for its playback rate and computed effect duration. The check drives a real animation in each realm and reads those values back, then requires them to agree. The reason this is worth asking at all is that animation timing is one of the surfaces automation tooling perturbs — either by disabling animations to make pages settle faster, or by driving the clock to skip waits — and doing that in the page world leaves a fresh realm reporting the honest values. Severity is warn rather than critical because the surface is small and a page-level library can legitimately wrap Element.animate.

How to resolve it

If the two realms disagree, something is intervening in animation timing in the page world. Nothing here needs fixing on an ordinary browser.

How automation gets caught, layer by layer

Environment & locale

timezone-presentA named IANA zone such as 'Europe/Amsterdam' or 'America/New_York', with a matching minute offset and a plausible navigator.language.

What a detector infers

This check reads `Intl.DateTimeFormat().resolvedOptions().timeZone` and records it alongside `-new Date().getTimezoneOffset()` and `navigator.language`. It passes as long as a non-empty zone string comes back, and it is 'info' severity — contextual, not scored. Its value is as a baseline reading rather than a verdict: the resolved zone is the anchor that the offset-coherence check compares against, and it is the signal a detector cross-references with IP geolocation and Accept-Language. An empty or unresolvable zone is unusual enough on its own to be worth surfacing, since real consumer browsers always resolve a named zone from the host ICU data.

How to resolve it

Nothing to fix if a zone resolves — this line is informational context for the timezone coherence checks below it. If it reads 'n/a', the ICU/timezone data in the environment is missing or Intl has been tampered with; restore a normal ICU build rather than stubbing Intl.

What is a timezone / IP mismatch?
timezone-not-utcA region zone that matches where the connection appears to originate — e.g. 'Europe/Berlin' behind a German exit.

What a detector infers

The check resolves the Intl zone and fails only when it is exactly 'UTC' or 'Etc/UTC'. The mechanism is population statistics, not tampering: virtually every server, container image and CI runner defaults to UTC, while consumer machines are configured to a local region during setup. So a detector reading UTC learns something about the host class — datacenter-shaped rather than desktop-shaped — and will weigh it more heavily when the egress IP resolves to a residential region that is plainly not UTC. Because a genuine user in Iceland or the UK in winter can legitimately be at UTC, the check is 'info' severity and is not scored.

How to resolve it

Set a real-region timezone that matches your proxy exit region (geoip=True, or timezone='Europe/Berlin'). Leave it as-is if your environment genuinely sits at UTC and that agrees with your egress.

What is a timezone / IP mismatch?
timezone-offset-coherenceBoth paths report the same minute offset for the resolved zone, e.g. 'IANA Europe/Amsterdam and Date both = 120min'.

What a detector infers

This is a two-source cross-check on the same fact. It takes the resolved IANA zone, formats the current instant through `Intl.DateTimeFormat` with that timeZone, reconstructs the wall-clock time as a UTC timestamp, and derives the implied offset in minutes. It then compares that against `-new Date().getTimezoneOffset()`, allowing 1 minute of slack for the second boundary. Intl and Date read the timezone through different internal paths, so a spoof applied to only one of them produces two numbers that disagree — a layered lie. A detector does not need to know your real zone to score this: the contradiction is self-contained and provable from inside the page. N/A when no zone resolves.

How to resolve it

Spoof the timezone at the engine level so Intl and Date derive from one source (geoip/timezone in clearcote). Do not patch `Date.prototype.getTimezoneOffset` or `Intl` in userland JS — patching one leaves the other reporting the host's real offset.

Coherence over camouflage: why a plausible identity beats a hidden one
has-pluginspdfViewerEnabled=true together with a non-zero plugin count (real Chrome exposes several PDF-viewer entries), or pdfViewerEnabled=false with an empty list.

What a detector infers

An empty `navigator.plugins` is only meaningful when it contradicts something else, and this check is written to respect that. It reads `navigator.pdfViewerEnabled` first: only when that is true does it require `navigator.plugins.length > 0`. Per spec, Chrome exposes its PDF-viewer plugin entries exactly when the PDF viewer is supported, so 'pdfViewerEnabled=true with 0 plugins' is a state real Chrome cannot produce — it means someone reported the flag without materialising the plugin array behind it. When pdfViewerEnabled is false or absent, the check returns N/A rather than failing, because mobile Chrome, iOS/WebKit and policy-disabled desktop all legitimately report no viewer and no plugins.

How to resolve it

Make the two agree at the source. If you are presenting desktop Chrome, run a build with the PDF viewer enabled so the plugin entries exist naturally, rather than setting pdfViewerEnabled or the plugin list independently from JS.

How is a headless browser detected?
pdf-viewertrue on a desktop Chrome profile with the built-in viewer; false or absent is legitimate on mobile, WebKit, or policy-restricted builds.

What a detector infers

A direct read of `navigator.pdfViewerEnabled`. It is N/A when the property is not exposed at all, passes when true, and is marked false otherwise — at 'info' severity, so it is contextual and not scored. On its own the flag proves little: false is entirely normal on mobile Chrome, on WebKit, and wherever enterprise policy disables the built-in viewer. Its real job in this audit is to supply the premise for the plugins check above, which only demands a populated plugin array when this flag claims a viewer exists. Read the two lines together, not separately.

How to resolve it

No fix required in isolation — this is a reading, not a verdict. Only act on it if it contradicts the plugin list (see has-plugins) or the platform you are presenting.

What is a headless browser?
plugins-mimetypesEvery mimeType maps back to a plugin object that is present in navigator.plugins — a fully closed graph.

What a detector infers

This walks `navigator.mimeTypes`, and for each entry reads `.enabledPlugin` and checks that the exact object is present in a Set built from `navigator.plugins`. It counts entries whose enabledPlugin is null or is an object absent from the plugin list. This tests object identity, not string equality: in real Chrome the two collections are two views over one internal registry, so every mimeType's enabledPlugin is necessarily the same object instance found in navigator.plugins. Spoofs that fabricate the two arrays independently — a common way to 'fill in' plugins — produce entries that point at nothing, or at plugin objects that do not appear in the list. N/A when there are no mimeTypes to check.

How to resolve it

Do not synthesise navigator.plugins and navigator.mimeTypes as separate JS arrays. Let the real plugin registry populate both, so the cross-references resolve to the same objects; if you must present a plugin set, it has to be wired at the engine level where both views share one backing store.

What is codec fingerprinting?
codec-supportH.264=true and AAC=true whenever a Chrome UA is presented — matching an official Chrome build's media stack.

What a detector infers

The check calls `canPlayType` on a video and audio element for H.264 ('avc1.42E01E') and AAC ('mp4a.40.2'), plus MP4/WebM/Ogg for context, and requires H.264 and AAC to both be supported. It only applies when the UA contains 'Chrome/' AND window.chrome is present (the IS_BLINK gate); otherwise N/A. The mechanism is build-configuration leakage: H.264 and AAC are licensed codecs compiled in for official Chrome builds, but stripped from many open-source Chromium builds and some headless/minimal images. A UA claiming desktop Chrome while the media stack cannot decode the formats every real Chrome can decode tells a detector the binary is not the browser it claims to be — and canPlayType is not something a UA string override reaches.

How to resolve it

Build or run a Chromium with proprietary_codecs enabled (ffmpeg_branding=Chrome) so the media stack matches the identity you present. Otherwise, do not advertise a Chrome UA on a codec-stripped build.

How automation gets caught, layer by layer
css-engine-surface-webkit-app-region supported, no Moz* properties in the style registry, for any Chrome UA.

What a detector infers

For any UA containing 'Chrome/', this requires `CSS.supports('-webkit-app-region', 'drag')` to be true and the Gecko markers — `MozAppearance` in the inline style object, or `CSS.supports('-moz-user-focus', 'normal')` — to be absent. The CSS property registry is compiled into the engine: which vendor-prefixed properties parse is decided at build time and surfaced through CSS.supports and the CSSStyleDeclaration shape. That gives a detector an engine identity check that runs entirely below the JS-spoofing layer — a Gecko browser wearing a Chrome UA still answers Blink's questions with Gecko's property table. N/A for non-Chrome UAs.

How to resolve it

Present a UA that matches the engine. This surface is compiled in and cannot be re-skinned from JS — adding shims for individual properties does not change what CSS.supports reports for the rest of the registry.

Identifying the engine behind the User-Agent
intl-locale-coherenceAll four Intl constructors resolve the same base locale tag, and the two that expose numberingSystem agree on it.

What a detector infers

Every Intl constructor — NumberFormat, DateTimeFormat, PluralRules, Collator — resolves its locale through the same underlying ICU data, so all four must report the same base language tag. This check constructs each one, reads resolvedOptions().locale, strips any -u- extension (each constructor legitimately keeps only its own extension keys, so comparing full tags would flag a genuine de-DE-u-co-phonebk user), and compares the base tags. It separately compares numberingSystem across NumberFormat and DateTimeFormat only, since Collator and PluralRules do not expose that field. A disagreement is informative because it indicates a partial patch: the common timezone/locale spoof targets Intl.DateTimeFormat.prototype.resolvedOptions and leaves the other three constructors reporting the real ICU locale. Severity is warn; if fewer than two constructors resolve, the check reports N/A.

How to resolve it

Set the locale at the ICU/engine level so all Intl constructors read the same data. If you patch resolvedOptions in JS, cover NumberFormat, DateTimeFormat, PluralRules and Collator consistently rather than DateTimeFormat alone.

What is a timezone/IP mismatch?
plugins-structured-clonestructuredClone(navigator.plugins) throws a DataCloneError.

What a detector infers

The structured-clone algorithm is implemented in C++ and interrogates objects about what they actually are, not what they claim. A genuine PluginArray is a non-serializable platform object, so structuredClone(navigator.plugins) must throw — and specifically must throw a DataCloneError. This check first confirms the gate (structuredClone exists and Object.prototype.toString.call(navigator.plugins) reports [object PluginArray]), reporting N/A otherwise, then requires that a throw occurs and that the error's name is DataCloneError. Only the error name is asserted, never its message, because wording varies by engine and entry point. A plain Array or object impersonating a PluginArray clones cleanly and silently succeeds — no JS construct can fabricate non-serializability — so a successful clone lets a detector infer the plugin list is a fake that merely carries the right toStringTag, and a differently-named error indicates a JS throw standing in for the serializer's.

How to resolve it

Don't synthesize navigator.plugins from a JS array or object. The serializer's refusal is a hard C++ invariant that a JS replacement cannot reproduce — matching Symbol.toStringTag gets past shallow type checks but not this one. If the plugin list needs to differ, change it at the engine level so the object remains a real PluginArray, or leave the native list intact.

What is headless browser detection?
canplaytype-element-agnosticAll 8 MIME strings return the same answer via <video>, <audio> and the prototype.

What a detector infers

canPlayType is implemented exactly once, on HTMLMediaElement.prototype, so <video> and <audio> inherit the same C++ function. This check asks eight MIME strings (H.264, AAC, bare video/mp4, audio/mpeg, VP8+Vorbis, VP9, Ogg Vorbis, WAV) three ways: through a <video> element, through an <audio> element, and through a saved HTMLMediaElement.prototype.canPlayType reference invoked with the video element as its receiver. On a genuine engine all three routes reach the same code and return identical strings, so any divergence is structural: it means the answers are being produced somewhere other than the shared implementation — a hook keyed on tagName, or a patch applied to HTMLVideoElement.prototype that never reached the HTMLMediaElement.prototype every element actually inherits from. A detector can infer from a single disagreement that codec answers are synthesized in JS, without needing to know which codecs the host really supports. Severity is critical because the contradiction is internal and needs no reference data to read.

How to resolve it

Never key canned codec answers off element type or patch only HTMLVideoElement.prototype. If codec answers must be changed, change them at the one place the engine implements them — HTMLMediaElement.prototype — so every element and every call route inherits the same result. Probes that ask a video element about audio codecs are routine, and an element-agnostic implementation answers them for free.

How anti-bot systems detect automation
canplaytype-parser-trapsA bogus codec returns "", and quoted and unquoted codecs params agree.

What a detector infers

A real canPlayType is an RFC-6381 parser, not a dictionary. This check exercises two properties only a parser has. First, it asks for video/mp4; codecs=bogus — an unknown codec, for which the spec requires the empty string; a lookup table keyed on literal MIME strings frequently misses the codecs parameter and answers "probably" for the mp4 container. Second, it checks that quoting is transparent, comparing audio/wav; codecs=1 against audio/wav; codecs="1" and video/mp4; codecs=avc1.42E01E against its quoted form: a parser tokenizes and strips the quotes, so both members of each pair must agree, while a string-keyed map sees two distinct keys and desyncs. Neither cell carries useful information about the host's real codec support, which is precisely why they discriminate — they probe implementation shape, not capability. Severity is critical because a mismatch is self-contained evidence that answers come from a table.

How to resolve it

Don't answer canPlayType from a map keyed on literal MIME strings. Either leave the native parser in place, or parse the type and codecs parameter properly — strip quoting, and return "" for any codec you don't actually claim. If a table is unavoidable, normalize the input through a real RFC-6381 tokenizer before lookup so both traps resolve consistently.

What is codec fingerprinting?
audio-context-coherenceA standard sampleRate, a small positive baseLatency, channelCount within maxChannelCount, channelInterpretation "speakers", and two identical offline renders.

What a detector infers

The check constructs a live AudioContext and reads four invariants — sampleRate, baseLatency, the destination node's channelCount against its maxChannelCount, and channelInterpretation — then renders a fixed OfflineAudioContext graph twice and compares the two results. Each assertion has a physical basis. sampleRate is the hardware's real rate and must be one of the standard values (44100 and 48000 dominate on desktop); baseLatency is a small positive fraction of a second; a destination cannot present more channels than its own maximum, and its interpretation defaults to "speakers". The determinism test is the sharpest: the offline audio graph is a pure function of its inputs on a real engine, so two identical renders must be byte-identical, and when they differ it means the audio stack is injecting per-call noise — the same anti-fingerprinting technique the audio hash exists to expose. A detector reading an impossible sampleRate, a latency or channel layout that cannot occur, or two disagreeing renders infers a synthesised or noised audio stack. Kasada's audio_context_sample_rate, base_latency, channel_count, channel_count_mode and channel_interpretation probes plus its audio_fingerprint read exactly this surface. N/A where Web Audio is unavailable.

How to resolve it

Leave the Web Audio stack native. Do not add per-call noise to OfflineAudioContext output — it makes two identical renders disagree, which is itself the tell. Keep sampleRate and the channel layout consistent with the host's real audio device.

Anatomy of a browser fingerprint: every signal, and why they must agree
speech-voicesContextual only: a voice list is reported if the host speech engine exposes one.

What a detector infers

speechSynthesis.getVoices() enumerates the host operating system's speech engine — a resource stealth stacks essentially never touch, which makes it a useful window onto the real environment underneath a configured browser. The check records how many voices are present, how many distinct languages they span, and which one is marked default. It is deliberately informational and contributes nothing to the score: a non-empty list reports true, and an empty list reports N/A rather than a failure. The reason is that emptiness has too many innocent causes to attribute — Linux hosts without speech-dispatcher, VDI sessions, Windows N editions, offline machines, or simply voices that haven't finished loading at probe time — so treating it as evidence would produce false positives on ordinary desktops.

How to resolve it

Nothing to fix — this cell is contextual and unscored. Read it as a description of the host: the voice set and its languages reflect the real OS speech engine, so compare it against the platform and locale you intend to present. An empty list is not a defect and is not counted against the result.

What is speech synthesis fingerprinting?
opendatabase-removedOn a Chrome ≥119 UA, window.openDatabase does not exist.

What a detector infers

WebSQL was removed from Chrome in version 119, so window.openDatabase should not exist on any newer Chromium. This check parses the Chrome major version out of the user-agent string and only applies on Blink when that major is 119 or above; every other case — Firefox, which never shipped WebSQL, Safari, which still does, or an older Chrome UA — reports N/A rather than a verdict. Within that gate the logic is a direct contradiction test: the UA claims a version whose engine deleted the API, so the API's presence means the engine predates the version string it is advertising. A detector reading both signals can infer the browser is an older Chromium wearing a newer version number, because no genuine build can be on both sides of the removal at once.

How to resolve it

Resolve the contradiction by aligning the claimed version with the engine actually running: either advertise the Chromium version you're really on, or run a build at or above the version you advertise. Deleting window.openDatabase from JS papers over this one cell while leaving every other version-gated API to disagree — upgrading the engine, or lowering the UA claim, is the coherent fix.

Coherence, and why it is scored this way
battery-spec-invariantsPass — charging/discharging times and level derive from one real power state, so all four invariants hold (a no-battery desktop reporting charging:true, level:1, dischargingTime:Infinity passes).

What a detector infers

The probe calls navigator.getBattery() once and records four fields together: level, charging, chargingTime, dischargingTime. The Battery Status spec makes three of them mutually dependent rather than free — if charging is true, dischargingTime must be Infinity; if charging is false, chargingTime must be Infinity; and level is a ratio confined to 0–1. The check asserts exactly those three, and nothing else. The mechanism a detector exploits is that these fields are derived from one underlying power state in a real implementation, but a configuration layer that writes each property independently has no shared state to derive them from — so it can emit charging:true alongside a finite dischargingTime, a combination the spec forbids and no real BatteryManager produces. That inference is reliable because it needs no baseline and no population data: the contradiction is internal to the four values and self-evident from the spec text. Note what this check deliberately does not treat as suspicious: a desktop with no battery reporting charging:true, level:1, chargingTime:0, dischargingTime:Infinity is fully coherent — every invariant holds — and it passes. The probe also omits the `!level` guard that CreepJS uses, because that guard misreports a genuine 0% battery as "unsupported"; a real 0 is a reading, not an absence.

How to resolve it

Derive the battery fields from a single consistent state instead of setting them independently: charging:true implies dischargingTime === Infinity, charging:false implies chargingTime === Infinity, and level must stay within 0–1. If there is no reason to synthesize a power state, leave BatteryManager native. On engines without navigator.getBattery (Firefox, Safari), or when the call throws or exceeds the probe's 1500ms timeout, status.battery is null and this row is not emitted at all — absence of the row is not a failure.

Spec invariants as lie detectors
media-kinds-vs-engineOn a Chromium-claiming UA, the device kinds include audiooutput (severity: warn).

What a detector infers

The probe calls navigator.mediaDevices.enumerateDevices() and keeps only each entry's .kind, sorted — never labels or deviceIds, which is what makes the reading safe to display and permission-independent. It then compares that kind set against the engine family the User-Agent claims, derived by claimedEngine(): a UA matching edg/chrome/chromium/crios maps to V8. The check runs only when the UA claims V8 and the kind list is non-empty, and passes when the list contains "audiooutput". The mechanism is a real engine-level divergence: Chromium enumerates audiooutput devices, Gecko does not implement that kind. So a device list shaped like Firefox's while the UA claims Chromium is a contradiction between two independent surfaces, and a detector can infer that the UA string was changed while the underlying media stack was not.

How to resolve it

Present a device list consistent with the claimed engine, or leave enumerateDevices native. If you spoof a Chromium UA, the enumerated kinds must include audiooutput as Chromium's own stack does. The row is skipped entirely — not failed — when the UA claims a non-V8 family, when enumerateDevices is unavailable, or when the list is empty, so a device-less container is never flagged on this basis.

What is codec fingerprinting?
mediacapabilities-vs-canplaytypeAll six codecs agree across both APIs; no hard contradiction (severity: warn).

What a detector infers

Two different APIs answer from the same platform decoder registry. The probe calls navigator.mediaCapabilities.decodingInfo() for six codecs (audio ogg/vorbis, ogg/flac, mp4a.40.2, mp3; video theora, avc1.42E01E) with a fixed config — video 1920x1080 @60fps 120kbps, audio 2ch 300kbps — and separately calls canPlayType() on a real <video> or <audio> element for the same codec string. The check only records a contradiction in two hard directions: canPlayType returns "probably" while decodingInfo reports supported=false, or canPlayType returns "" (explicit no) while decodingInfo reports supported=true. The vague middle answer "maybe" is deliberately never treated as a failure, because the spec permits it to mean "cannot commit". Because both surfaces are backed by one registry, a hard disagreement cannot arise from a real decoder — it means one of the two surfaces was overridden and the other was not, which a detector can read as a partial patch rather than a genuine platform.

How to resolve it

Patch both surfaces consistently, or neither. If you override canPlayType to add or remove a codec, mediaCapabilities.decodingInfo must report the same supported value for that codec — and vice versa. Leaving both native is the reliable option. Note the row is skipped entirely (no result) when navigator.mediaCapabilities.decodingInfo is absent, since there is nothing to cross-check.

What is codec fingerprinting?

TLS & the network layer

ja4-stable-across-connectionsTwo independent handshakes from one browser produce byte-identical JA4 fingerprints, because the ClientHello is built from compiled-in preferences rather than generated per connection.

What a detector infers

Everything else on this page reads the browser through JavaScript. This reads it through its own TLS handshake — the one surface a page genuinely cannot see. JA3 and JA4 are computed from the raw ClientHello: the cipher list, the ORDER of the extensions, the curves, ALPN, and where GREASE was injected. None of it is reachable from script, and not for privacy reasons — it is emitted by the TLS stack before a line of page code exists. Only a server that terminated the handshake knows it, which is why this check is the only one here that leaves the machine. The audit fetches two probes on one host across two ports. Different ports are different origins, so the browser cannot satisfy the second fetch by reusing the first connection, and its TLS session cache is keyed per port — each probe forces a full, fresh ClientHello. The probe server also refuses to issue session tickets, and that detail is load-bearing: a resumed handshake carries pre_shared_key extensions the first one did not, which moves the extension count and the JA4 hash, and an entirely honest browser would look like it had randomised its own fingerprint. Now the assertion, and what it deliberately is not. It does not ask whether your JA4 is Chrome's. That needs a fingerprint corpus, and corpora rot — this audit already shipped one, a table of V8 heap sizes, which went stale and started calling a stock Chrome implausible; a JA4 database rots faster still, because Chrome's ClientHello moves between releases. Instead it asks the one question that needs no reference at all: one TLS stack, asked twice, must answer twice the same. A browser's ClientHello is assembled from preferences compiled into the binary, so it cannot vary between two connections seconds apart. Tooling that randomises the ClientHello to defeat fingerprinting is caught by the randomisation itself — the variation is the signal.

How to resolve it

A randomised ClientHello is more legible than a fixed one, not less: no real TLS stack rewrites its own cipher and extension list between two connections seconds apart, so the variation is itself the finding. If your TLS profile must differ from the browser's own, it has to be a consistent profile — impersonate one stack completely rather than sampling a new one per connection.

What is a JA3 / JA4 TLS fingerprint?
tls-fingerprint-readoutContextual only: this is your TLS fingerprint as a server sees it. There is no right answer, and none is asserted.

What a detector infers

This row reports rather than judges, and the reason it exists is that it is the only place on this page where the browser is described by something other than itself. JA4 condenses the ClientHello into a readable shape: the transport and TLS version, whether SNI was sent, how many ciphers and extensions were offered, the first ALPN protocol, and two hashes over the sorted cipher and extension lists. JA3 is the older form — an MD5 over the same material in wire order — and it is shown alongside because it is what most published research and most vendor documentation still quotes. GREASE is worth watching: Chrome injects reserved values into its cipher and extension lists on purpose, so their presence or absence says something about the stack that no user agent string can. What this row will never do is name your browser from its JA4. That requires a corpus of known fingerprints, and a corpus goes stale — Chrome's ClientHello changes between releases, and a table that is right today starts accusing honest browsers a few versions later. This audit made exactly that mistake once with a table of V8 heap sizes, and the lesson generalises: a check should go quiet when it meets something it does not recognise, never go wrong. So the fingerprint is shown, explained, and left for you to interpret — while the scored claim next to it is the one that needs no table at all.

How to resolve it

Nothing to fix. If you are presenting a browser identity, note that this fingerprint is decided by the TLS library your client links against and is entirely independent of the user agent string, navigator, or anything else JavaScript can set — the two are trivial to make disagree, and a server compares them for free.

What is a JA3 / JA4 TLS fingerprint?
tls-browser-identityA versioned match, or N/A when unknown.

What a detector infers

JA4 repeatability proves stability, not that the profile matches the claimed browser. Only versioned probe evidence adjudicates identity.

How to resolve it

Use the claimed browser's native TLS stack and maintain the server reference set.

http2-fingerprint-readoutProbe evidence or N/A.

What a detector infers

HTTP/2 SETTINGS and pseudo-header order are emitted below JavaScript.

How to resolve it

Add HTTP/2 capture to the transport probe.

http3-quic-readoutHTTP/3 evidence or N/A.

What a detector infers

QUIC versions and transport parameters form an independent fingerprint.

How to resolve it

Add an HTTP/3-capable first-party listener.

network-reputation-readoutSourced context or N/A.

What a detector infers

ASN, network class, and proxy/VPN/Tor indicators describe the route, not the browser, and are never standalone verdicts.

How to resolve it

Use a documented server-side provider.

dns-path-readoutResolver/egress context or N/A.

What a detector infers

Resolver identity requires an authoritative DNS canary; page JavaScript cannot inspect it.

How to resolve it

Operate a privacy-documented DNS canary.

Connection & locale

ip-tz-offset-deltaContextual only: your clock's offset and the offset of your IP's timezone agree at the same instant, or differ by an amount travel and DST explain.

What a detector infers

This is the only check here whose two sides are observed by different parties. The browser reports its own UTC offset; the edge reports the IANA timezone it derived from the address the connection arrived on. Both are then resolved AT THE SAME INSTANT — the IP's zone offset is computed for the current moment via Intl rather than read from a stored table, which is what makes the comparison survive daylight saving. (Comparing against a stored offset produces a bug that passes every test written in July and fires in October, and because the hemispheres transition in different windows it fires for different readers at different times of year.) The mechanism a detector relies on is that these two facts have separate causes: your clock is a setting on your machine, while your IP is a consequence of the route your packets took. A proxy or VPN relocates the second and cannot touch the first, so the pair drifts apart. This is graded rather than binary and is never a verdict: it is reported as a delta, because the honest reading of a mismatch is "these two are configured differently", not "this person is lying".

How to resolve it

If you are presenting a location, make the machine's timezone match the exit IP's region — the clock is the half you control. A mismatch is genuinely ordinary for VPN users, travellers, expats and developers who run their machines in UTC, which is exactly why this row is contextual, contributes nothing to the score, and never renders as a failure.

What is a timezone / IP mismatch?
ip-country-vs-tz-zone-setContextual only: the country your connection arrives from is one of the countries tzdb says observe your browser's timezone.

What a detector infers

Where the offset check compares two numbers, this one compares a place. tzdb's zone1970.tab records which countries observe each zone, so the browser's IANA timezone implies a set of countries, and the edge reports the country your connection arrived from; the check asks whether the second is a member of the first. Two details are load-bearing and getting either wrong would manufacture false positives. First, the country column is a LIST, not a value — Europe/Zurich is observed in {CH, DE, LI}, because Büsingen is a German exclave and Liechtenstein has no zone of its own — so the test is set membership and never string equality. Second, browsers emit deprecated zone names: tzdb renames zones and keeps the old names working forever, so Chrome still reports Asia/Calcutta and Europe/Kiev while the table only ever contains Asia/Kolkata and Europe/Kyiv. The check resolves those aliases before comparing, because skipping that step would confidently accuse ordinary Indian and Ukrainian users of incoherence. This check adds what the offset check cannot see: two countries can share an offset while being nowhere near each other.

How to resolve it

Align the machine's timezone with the region the exit address belongs to, rather than only matching the UTC offset — the offset is shared by countries on opposite sides of the planet, so matching it is not the same as being coherent. As with the offset row, a VPN or a trip abroad produces a mismatch honestly, which is why this is unscored and never a verdict.

What is a timezone / IP mismatch?

Input & behaviour

pointer-path-linearityA real hand deviates several pixels RMS from the straight line and lands well under 99.9% efficiency; a linearly interpolated path reports 0.00px and 100.00%.

What a detector infers

The check captures the pointer path across the pad and measures two things about its geometry: the RMS perpendicular distance of every sample from the straight line joining the first and last point, and move efficiency — straight-line distance divided by the total path length actually travelled. Automation interpolates. Playwright's Mouse.move() computes each intermediate point as fromX + (x - fromX) * (i / steps), placing every sample exactly on the line between the endpoints, which yields a residual of 0.00px and an efficiency of 1.000. A human arm is a two-joint linkage steering a device across a surface: it accelerates, overshoots, and corrects, so it cannot trace a mathematically perfect line. That makes this a contradiction rather than a rarity — it needs no reference distribution and no population to compare against, which is also why it cannot go stale when a browser updates. It is gated hard on purpose: axis-aligned paths are excluded outright, because the operating system's Mouse Keys accessibility feature drives the cursor with the arrow keys and legitimately produces perfectly straight horizontal and vertical motion.

How to resolve it

Do not synthesise pointer motion as a straight interpolation between two points. Drive input through a source that produces genuine curvature and correction — set waypoints and let a real humanising layer generate the trajectory, rather than hand-rolling per-step easing, which usually just swaps one analytic curve for another. This row is never scored, and reports N/A rather than a failure for touch-only or keyboard-only visitors, who produce no pointer path at all.

What is behavioural bot detection?
pointer-kinematicsPhysical variation over a sufficient sample.

What a detector infers

Velocity, acceleration reversals, direction changes, and timestamp quantisation catch constant-speed curved automation missed by linearity. It remains unscored.

How to resolve it

Use only as one feature in a validated model.

pointer-event-cadenceGaps between samples cluster at roughly 4–20ms with real variation, and no meaningful share arrive under 1ms apart.

What a detector infers

Where the linearity check reads the path's shape, this one reads its clock. It measures the gaps between consecutive pointer samples, reading PointerEvent.getCoalescedEvents() where available — the browser batches a high-frequency mouse into a single dispatched event, so the raw dispatch rate would otherwise measure the compositor's frame loop rather than the device. Two distinct signatures follow. A physical mouse or trackpad reports at its own hardware rate, so gaps cluster in the single-digit to low-tens of milliseconds with natural variation. Automation that emits every interpolated step with no delay lands the entire path inside one event-loop turn, collapsing the gaps toward zero. Automation configured with a fixed per-step delay produces the opposite tell: gaps that are near-identical, with a coefficient of variation close to zero. Hardware jitters; a timer does not.

How to resolve it

If input is driven programmatically, ensure each movement is actually dispatched over real time rather than queued into a single turn, and that the interval between steps varies rather than being constant. A fixed delay is as legible as no delay. Unscored, and N/A when too few samples exist to measure.

What is behavioural bot detection?
keystroke-timing-distributionContextual only: dwell and flight are both above zero and vary between keys, consistent with a hand rather than a timer.

What a detector infers

The probe records only two timings per key: dwell, from keydown to keyup, and flight, from one key's release to the next key's press. No characters are stored. Playwright's Keyboard.type() reads (options && options.delay) || undefined and emits with no pause when the option is unset, so both collapse to approximately zero — keys that go down and up in the same instant were not pressed by a finger. A configured constant delay produces the inverse signature: flight times that barely vary. The check is informational and never scored, for a reason worth stating plainly: fifteen keystrokes cannot support a claim about the shape of a distribution, and asserting otherwise would be exactly the overclaiming this page exists to criticise. It also has many innocent silent cases — clipboard paste, password managers and input method editors all decouple keystrokes from characters and legitimately produce no per-key timing at all.

How to resolve it

Nothing to fix if you are a human typing. If input is synthesised, note that both an absent delay and a fixed delay are visible — the first as zero-length dwell, the second as flight times with almost no variance. This cell is unscored, and a short or empty sample is reported as such rather than counted against anything.

What is behavioural bot detection?
event-istrusted-provenanceContextual only: real input reports isTrusted=true, and an event dispatched by this page reports isTrusted=false.

What a detector infers

This row exists to debunk a check rather than to run one, which is why it can never fail. Event.isTrusted is true when the user agent generated an event and false when page script dispatched it via EventTarget.dispatchEvent — so the pad dispatches its own event beside your real one and shows the two side by side, one flag apart. The widespread belief that isTrusted therefore detects automation is wrong, and the reason is architectural: CDP's Input.dispatchMouseEvent — the mechanism underneath Puppeteer and Playwright — injects at the browser's input pipeline, above the renderer, so the event is generated by the user agent exactly as a real one is and reports isTrusted=true. The flag separates page script from the input pipeline. It says nothing about whether a human is at the other end of that pipeline, which is precisely why the geometric and timing checks above have to do the actual work.

How to resolve it

Nothing to fix, and nothing to read into a pass. Treat isTrusted=true as evidence only that an event arrived through the input pipeline rather than from page script — CDP-injected input satisfies it trivially. If you are evaluating detection techniques, this is a good example of one that is widely cited and carries far less information than its reputation suggests.

How automation gets caught, layer by layer