Skip to content
Interactive · free · no sign-up

Browser fingerprint test

A browser fingerprint is not one value — it is dozens of signals, read from separate subsystems, that all describe the same machine. Because they describe the same machine, they have to agree. This page asks your browser the same question twice through different APIs, and shows you where its answers disagree.

It is a teaching tool. Every check states what it observed, what a detector can infer from it, and what to change — each with a link to the research behind it. Every check runs locally, and your raw fingerprint — the canvas hash, the fonts, the full user agent — never leaves the page.

Two things do leave, and both are worth knowing before you press Run. The WebRTC test asks a public STUN server which address your media traffic exits from, so that operator sees your IP — it is the only way to catch a WebRTC leak. And a tickbox next to the button, on by default, contributes a coarse description of your configuration to a public research dataset so the page can tell you how rare your setup is. Untick it and nothing is sent; every check still runs.

Audit this browser's fingerprint

Runs every probe below and reports, for each one, what it observed, why that signal is legible to a detector, and how to resolve it.

No baseline. Every check runs locally, and the raw fingerprint — the canvas hash, the fonts, the full UA — never leaves this page.

One exception: the WebRTC check contacts a public STUN server (stun.l.google.com) to learn the address your media traffic exits from, so Google sees your IP. It is the only way to catch a WebRTC leak, and it is the only outbound call this page makes.

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.

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 — zero with overlay scrollbars, up to about twenty pixels with classic ones — because both numbers describe the same surface, one measured by the layout engine and the other reported by the compositor. 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?

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, and a desktop Chrome UA on a build that lacks it is not the desktop Chromium it says it is. 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, globalPrivacyControl and isSecureContext report identically in the page and in a fresh realm.

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, navigator.globalPrivacyControl reflects a privacy setting, and isSecureContext reflects how the page was loaded. All four 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

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.

Anatomy of a browser fingerprint: every signal, and why they must agree
jsheap-latticeContextual only: a real reading usually sits on the known lattice (within 1%), but a value this table has not seen yet is far more likely a new V8 release than a fabricated number.

What a detector infers

The probe reads performance.memory.jsHeapSizeLimit, and the check runs only when that value is non-null (performance.memory is a Chromium surface, so on other engines the row is simply absent). The mechanism is real and worth knowing: V8 does not compute this limit from available system RAM in a continuous way, it selects from a small discrete set determined at build time by pointer width and heap configuration, so genuine readings cluster on a lattice rather than spreading across a range. A detector can therefore treat the value as categorical rather than numeric — an arbitrary-but-plausible-looking number like 3000000000 lands in a gap where V8 never places the limit, revealing that it was chosen rather than reported. This row is nevertheless CONTEXTUAL and is never scored, and the reason is a caution worth generalising. Every other check on this page is self-referential: it compares the browser against itself, so when it meets something it does not recognise it goes quiet. This one compares against a table of values maintained by hand — so when V8 picks a limit the table has never seen, it does not go quiet, it goes wrong. That is not hypothetical. A stock Chrome 150 on Windows reports 4395630592, which was missing from the table, and the check duly called an ordinary unmodified browser implausible. The table has since been corrected, and it will fall behind again on some future release. A check whose own advice is 'the set drifts across versions' has no business costing anyone points, and the honest reading of an unrecognised value here is 'a V8 newer than this table', not 'a spoof'.

How to resolve it

If you are not setting this value, there is nothing to fix — this row is not scored precisely because an unrecognised limit is more likely a V8 version newer than the table than a spoof. If you are setting it, use one of V8's real discrete heap limits rather than an arbitrary 'realistic' number, and prefer leaving performance.memory native: a hardcoded value that is correct today drifts off-lattice after an engine update. On engines without performance.memory the row is not emitted at all.

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.

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.

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

Automation surface

webdriver-offnavigator.webdriver is false or undefined.

What a detector infers

This check reads navigator.webdriver and passes when the value is falsy (false or undefined). The property exists because the WebDriver specification requires a conforming browser to advertise that it is under remote control; Chromium sets it when launched with the automation flag. It is a self-report, not an inference — a detector that reads true does not need to reason about it, because a browser only reports true when the automation surface is genuinely active. That makes it the cheapest possible signal and the first one any script reads. Passing here means only that the flag is not on; it says nothing about how the property is implemented, which the descriptor-integrity check examines 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?
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?

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: V8 emits string-keyed own properties in insertion order, and a realm installs its globals in a fixed order at setup, so the names shared by both realms must appear in the same relative sequence. 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

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?

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?

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-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