Most anti-detect tooling still optimizes the wrong objective: it treats fingerprinting as a collection problem, enumerate every readable property, then make each one plausible. But a defender does not grade properties in isolation. It grades whether the values you emit across independent layers could have come from the same real machine. A canvas hash that survives CreepJS tells you nothing if your TLS ClientHello says Chrome 126 and your navigator.userAgent says 138. Detection in 2026 is a consistency problem and the two layers it spans are ones a single browser process cannot independently steer.
The two layers and why they resist coordination
Split a browser session into where signals are produced and who can observe them.
Layer 1, the JavaScript fingerprint. Everything readable from inside the page: navigator and UA Client Hints, canvas, WebGL/WebGPU, AudioContext, font metrics, WebRTC ICE candidates, Intl, permissions. This layer is addressable from JS, which is exactly why it is easy to spoof and easy to catch spoofing. The same interface you use to lie is the one the detector uses to test whether you lied.
Layer 2, the network/TLS layer. The IP and its ASN, the timezone geolocated from that IP, the negotiated HTTP protocol and the TLS ClientHello (its cipher list, extension set and ordering, the raw material of JA3/JA4). This is observed at the edge before a single line of your JS runs and your page JS can never read it about itself. There is no navigator.tlsClientHello. The handshake was emitted by BoringSSL microseconds before your script existed.
A genuine Chrome on a genuine Windows box produces both layers coherently for free: the same binary that reports Chrome/138 to navigator.userAgent is the one whose BoringSSL emitted a Chrome-138-shaped ClientHello, connecting from an IP whose geo-timezone matches the machine’s Intl.DateTimeFormat().resolvedOptions().timeZone. A spoofed profile has to fake both layers and keep them agreeing, using tooling that typically only has a good grip on one of them.
“The fingerprint is not the problem. The problem is that the fingerprint has to agree with everything else you can’t see.” paraphrasing the recurring theme in the niespodd/browser-fingerprinting notes
The asymmetry is the whole game
Cloudflare hands the origin a request.cf object. The fields that matter here:
request.cf field |
What it reveals | Can page JS read it? |
|---|---|---|
asn / colo |
Hosting vs residential ASN, edge PoP | No |
tlsVersion |
Negotiated TLS (1.2 / 1.3) | No |
tlsClientHelloLength |
ClientHello byte length | No |
tlsClientExtensionsSha1 |
Hash of extension set/order (JA3-ish) | No |
httpProtocol |
h1 / h2 / h3 | No |
| IP-derived timezone | Geolocated zone for the connecting IP | No |
Every row is invisible to the code running in your tab. That asymmetry is the mechanism, not a side effect. The defender knows something about you that your own spoofing kernel cannot inspect, cannot compare against and therefore cannot keep consistent by construction. It has to guess what its own network layer looks like and hope the guess matches the JS story it is telling.
The observer side has an honest limit too: full JA3/JA4 strings and true wire-order HTTP/2 frame fingerprints require a TLS-terminating origin or an Enterprise plan. On a standard Worker you get tlsClientExtensionsSha1 and tlsClientHelloLength, the JA3-ish bits, not the full string. Enough to bucket a handshake; not enough to reproduce it. Services like tls.peet.ws exist precisely because operators need to see the layer their browser hides from them.
What a cross-layer scanner actually checks
Our project’s scoring model is deliberately unglamorous: it starts at 100 and deducts per mismatch, across every check, weighted by how load-bearing each disagreement is. The interesting checks are the ones that straddle the two layers, where a value collected in-browser is set against a value observed at the edge.
// HTTP User-Agent header (edge) vs navigator.userAgent (JS)
if (server.userAgent !== ua)
add('ua-http-js', 'HTTP UA ↔ navigator.userAgent', 'bad',
'The User-Agent request header differs from navigator.userAgent (JS override tell).', 25);
// UA-CH fullVersionList major (JS) vs UA string major (JS/header)
if (chMajor && chMajor !== major)
add('uach-ver', 'UA-CH ↔ UA version', 'bad',
`UA string is Chrome ${major} but UA-CH fullVersionList says ${chMajor}.`, 15);
// IP-derived timezone (edge) vs Intl timezone (JS)
if (server.ipTimezone !== client.intl.timezone)
add('tz', 'IP timezone ↔ browser timezone', 'warn',
`IP resolves to ${server.ipTimezone} but the browser reports ${client.intl.timezone}.`, 12);
The heavy hitters, by weight:
- HTTP UA header ↔
navigator.userAgent(−25). The classic tell of a JS-only override: you patched the DOM but not the header the network stack already sent. Sec-CH-UA-Platform↔navigator.platform(−15). Claim"Windows"in the Client Hint, report"MacIntel"in JS and you’re done.- UA-CH
fullVersionListmajor ↔ UA string major (−15). Two version numbers, two code paths, one that got updated and one that didn’t. - IP-timezone ↔
Intltimezone (−12). The signature of a datacenter proxy in front of a browser configured for a different region. Accept-Languageheader ↔navigator.languages(−8) and WebRTC public IP ↔ connection IP (−10): the STUN candidate that reveals the real path behind the proxy.- HTTP/TLS sanity vs claimed Chrome (−6 each). A “Chrome 138” that negotiated HTTP/1.1 or TLS 1.2 is telling on itself; real modern Chrome insists on h2/h3 and TLS 1.3.
None of these require exotic instrumentation. They require two vantage points and a subtraction.
Consistency also lives inside the JS layer
The two-layer framing is the headline but the same logic (does this value agree with that value?) cuts through Layer 1 on its own. A spoofing kernel that patches one interface but forgets its sibling leaves the same kind of contradiction:
- WebGL unmasked renderer ↔ WebGPU
adapter.info.vendor(−22). Spoof the oldUNMASKED_RENDERER_WEBGLstring but forget thatnavigator.gpu.requestAdapter()now exposes vendor/architecture too and you report an NVIDIA GPU on one interface and an Intel one on the other. - Main-thread UA ↔ Web Worker
navigator.userAgent(−18). Overrides injected into the page context frequently don’t propagate into a freshly spawned worker. - Canvas / DOMRect double-read stability (−18 / −12). Read the same canvas twice; if per-call randomization is in play, the two hashes differ. Real hardware is deterministic across identical draws. Per-call noise is itself the lie, regardless of how convincing any single read looks.
Notification.permission↔Permissions.query({name:'notifications'}).state(−15). Two APIs describing one permission; headless and sloppy spoofs desync them.Intlzone offset ↔Date.getTimezoneOffset()(−15). Set the IANA zone string but leave the process TZ untouched and the implied offset won’t match.
A real browser derives all of these from a single underlying state, so they cannot disagree. A spoof derives them from separate patches, so keeping them agreeing is manual labor that scales with the number of interfaces and the interface count only grows.
The numbers say the network layer decides first
The 2026 Bamberg study, Detecting Bot Detection, crawled the Tranco Top 1M and is blunt about ordering. Of the sites that blocked a Chromium-headless client, 75% triggered on HTTP-header-layer signals alone, the decision made before JS mattered. Below that first gate, 46% probed at least one automation-only JS property and 34% specifically read navigator.webdriver.
| Signal / vendor | Rate | Note |
|---|---|---|
| Headless blocked on header-layer signals alone | 75% | decided pre-JS |
| Sites probing ≥1 automation-only JS property | 46% | |
Sites probing navigator.webdriver |
34% | |
| Cloudflare block rate | ~37% | |
| Akamai block rate | ~26% | highest among the big CDNs |
| Enterprise bot-management prevalence | <1% overall | ~15.5% on high-value scraped sites |
Three-quarters of blocks happen at a layer your page JS never sees. You can pass every CreepJS check ever written and still be gone before your bundle parses.
The structural blind spot of JS-layer spoofing
The anti-detect-browser approach has an honest limit. A JS-layer spoofing kernel patches fingerprints in the C++ engine and the JS runtime. It does not rewrite BoringSSL’s ClientHello and it does not reorder the HTTP/2 frames the network stack emits. So its outbound TLS fingerprint is still a real Chrome’s TLS fingerprint, because it is a real Chrome underneath.
That’s fine right up until a defender classifies at the handshake. Akamai, Cloudflare and DataDome all bucket connections by JA3/JA4 and HTTP/2 shape before serving your JS. If your ClientHello says “Chrome 138 on Windows” but your painstakingly-spoofed JS says “Chrome 131 on macOS,” the contradiction is logged at the edge and no amount of Layer-1 polish closes it. Worse, when the TLS layer says Chrome and the JS layer also says Chrome but the two disagree on version or platform, you’ve produced a signal no real browser can produce: a machine lying about itself.
This is why the defender community converges on a class-level verdict. As ex-DataDome research VP Antoine Vastel and the niespodd notes have argued for years, commercial anti-detect browsers get treated as one detectable class: “a real Chrome running a fake fingerprint.” CDP/automation residue and cross-layer inconsistency betray the category regardless of how good any single dimension is. Perfecting your canvas noise does not move you out of that bucket; it just makes you a well-dressed member of it.
Takeaway
Stop scoring your fingerprint one property at a time. The defender doesn’t ask whether this canvas hash is plausible. It asks whether this canvas hash, this ClientHello, this ASN and this timezone could all belong to the same real machine. That’s a consistency question spanning a layer you control and a layer you can’t read and the only client that answers it correctly by construction is a real browser being itself. Everything else is doing bookkeeping across interfaces that grow faster than the bookkeeping can keep up. Measure the disagreements, because that’s what the edge is measuring.