Skip to content
Detection teardowns

How automation gets caught, layer by layer

From the WebDriver flag to the TLS handshake — the distinct layers a detector reads, and why fixing one rarely helps.

Pim
Pim· Clearcote Research
10 min read

Key takeaways

  • Detection reads several independent layers — driver, CDP, fingerprint, network, behaviour — and each has its own tells.
  • JavaScript stealth patches touch only one layer and self-reveal: an overridden getter fails Function.prototype.toString and a fresh-realm re-read.
  • The network layer (JA3/JA4 TLS, HTTP/2 settings) is below JavaScript's reach, so a spoofed-JS-over-real-network setup is caught before the page runs.

Not one check — a stack of them

There is no single "is this a bot?" flag. A page-load is inspected by several structurally different layers, each reading a different surface. A setup can be flawless on one and betray itself on another, which is why patching a single value rarely moves the needle.

Read from the outside in, the layers are: driver/binary artifacts, Chrome DevTools Protocol (CDP) side-effects, the JavaScript fingerprint surface, the network stack, and behaviour. Here is the concrete signal at each.

1. Driver & binary artifacts

The most direct tell is navigator.webdriver, a standardized property that reports true when the browser is launched under automation (for example with the --enable-automation switch). One line reads it.

Older driver stacks leak more: ChromeDriver injects globals with a cdc_-prefixed name (e.g. window.$cdc_asdjflasutopfhvcZLmcfl_, document.__webdriver_evaluate) that a page finds by enumerating Object.getOwnPropertyNames(window). Automation defaults also stand out — Puppeteer's 800×600 viewport, Playwright's 1280×720, or a HeadlessChrome User-Agent — sizes and strings a real user rarely has. See why navigator.webdriver reveals automation.

2. CDP side-effects

Playwright and Puppeteer drive Chromium over CDP. Using it is invisible by itself, but several operations have observable side-effects. The best-known is the Runtime.enable leak: to find a frame's execution context, libraries enable the runtime domain, which changes how the browser serializes objects for a listening debugger — a page can trip it with a crafted console call and an Error.stack getter.

Injected setup scripts leave residue too: init scripts added via Page.addScriptToEvaluateOnNewDocument leave globals like window.__pwInitScripts, and a unique //# sourceURL marker can surface in error.stack. Evaluating in the page's main world (rather than an isolated one) is catchable via MutationObserver or function-tampering checks.

A quieter gap is out-of-process iframes (OOPIF): a cross-origin child frame runs in its own process, so main-frame patches often don't reach it — a detector reads iframe.contentWindow.navigator.webdriver and finds the un-spoofed value. See what CDP detection is.

3. The fingerprint surface

This is the layer most tooling focuses on: canvas, WebGL, audio and fonts, plus navigator. A frequent tell here is the GPU — headless and virtualized environments report software renderers like Google SwiftShader or llvmpipe in the WebGL UNMASKED_RENDERER string, which contradicts a claim to be a real machine.

Two consistency checks dominate. First, the User-Agent must agree with the structured UA Client Hints (Sec-CH-UA-Platform, navigator.userAgentData) — a Chrome-90+ UA with no Sec-CH-UA at all is impossible. Second, the same probes are re-run inside a Web Worker and an iframe; a spoof applied only to the main-thread navigator leaves WorkerNavigator reporting the truth. See browser fingerprinting and the anatomy of a fingerprint.

4. The network layer

Below JavaScript entirely, the TLS ClientHello is summarized as a JA3 or JA4 hash from fields like the cipher list, extensions and curves. JA3 became noisy after Chrome 110 began randomizing extension order; JA4 sorts the extensions before hashing to stay stable. A browser that claims Chrome but presents a non-Chrome handshake is incoherent before any HTTP is sent — see JA3/JA4.

HTTP/2 adds its own signature: the SETTINGS values, window sizes, and pseudo-header order. The numbers are discriminating — Chrome's INITIAL_WINDOW_SIZE is 6,291,456 while a common Go HTTP client sends 65,535, and Chrome orders its pseudo-headers :method, :authority, :scheme, :path. A UA claiming Chrome over a mismatched HTTP/2 signature is caught pre-content. See HTTP/2 fingerprinting.

5. Behaviour

Even a coherent fingerprint can be flagged by how a session acts. Detectors register listeners for mousemove, scroll, keydown and friends and score the geometry and timing: human cursor paths are curved with variable velocity and micro-jitter, while scripted motion is linear or lands clicks exactly at element centre with no preceding movement.

A note on event.isTrusted: modern engines mark CDP-dispatched input as trusted, so that flag alone is weak — the movement profile is what carries the signal. See behavioural bot detection.

Why single-layer JavaScript fixes self-reveal

The layers are the reason JavaScript stealth struggles. A native accessor stringifies to { [native code] }; an override returns its own source, so a detector catches a spoof by calling Function.prototype.toString on it — and even a patched toString is defeated by re-acquiring a pristine copy from a fresh iframe or worker realm.

Because the getters are governed in the C++ engine, an engine-level browser reads as native in every realm, and its JavaScript agrees with the real TLS/HTTP-2 stack underneath — there is no seam to cross-check. That is the argument for coherence over camouflage; the detection breakdown covers the mechanics.

#bot-detection#cdp#fingerprinting#tls#reference

Clearcote puts this into practice

Open-source, engine-level, coherent down to the TLS handshake — a drop-in for Playwright & Puppeteer.