Every mainstream anti-bot system—Cloudflare Turnstile, Akamai Bot Manager,
PerimeterX (now HUMAN), DataDome, Kasada—ships a JavaScript challenge
that fingerprints the browser environment at multiple layers simultaneously.
Headless Chrome, the default runtime for Playwright, Puppeteer, and every
agent framework built on top of them, fails these checks within milliseconds.
The navigator.webdriver flag is the most obvious tell, but it
is nowhere near the only one.
This post breaks down exactly how detection works at each layer—JavaScript API surface, rendering pipeline, TLS handshake, and behavioral biometrics—and the architecture decisions we made to solve each one at the runtime level rather than with fragile userland patches.
The fingerprinting stack
Anti-bot vendors fingerprint across four independent layers. A browser
session must pass all four simultaneously; failing any single one
triggers a block or CAPTCHA. Understanding the full stack is essential
because point-fixes at one layer (e.g., overriding navigator.webdriver)
are meaningless if TLS or canvas fingerprints are inconsistent.
| Layer | Signal | What it detects |
|---|---|---|
| JS API surface | navigator.webdriver |
Automation flag set by CDP connection |
| JS API surface | navigator.plugins.length |
Headless has 0 plugins; real Chrome has 3+ |
| JS API surface | navigator.languages |
Headless returns empty or inconsistent locale arrays |
| Rendering | Canvas hash | GPU-dependent pixel output; headless renders differently |
| Rendering | WebGL renderer string | ANGLE (... vs. real GPU; SwiftShader detected |
| Rendering | AudioContext fingerprint | DynamicsCompressor output varies by audio stack |
| TLS | JA3 / JA4 hash | ClientHello cipher suites + extensions must match declared UA |
| TLS | HTTP/2 SETTINGS frame | Akamai fingerprints H2 initial window size + priority tree |
| Behavioral | Mouse / scroll entropy | Synthetic events have zero jitter; human input has Gaussian noise |
| Behavioral | Keystroke cadence | Programmatic input fires at constant intervals |
Why userland patches fail
The most common approach to anti-detection is monkey-patching JavaScript globals.
Libraries like puppeteer-extra-plugin-stealth override
navigator.webdriver, inject fake plugin arrays, and spoof
window.chrome. This worked in 2020. It does not work today.
Modern challenge scripts use multiple detection vectors that cannot be patched from userland:
-
CDP timing side-channel. Challenge scripts measure the
round-trip latency of
Runtime.evaluate. When CDP is connected, the latency profile is measurably different (<1ms vs. 2-5ms for real user-initiated evaluation). This timing difference persists regardless of flag overrides. -
Iframe sandbox probing. A cross-origin iframe is created
and its
contentWindow.chromeobject is inspected. Stealth plugins patch the parent frame but miss child iframes, exposing the real automation state. -
Stack trace analysis.
Error().stackis parsed inside getter traps placed onnavigatorproperties. If the stack trace originates from a CDP evaluation context (identifiable by__puppeteer_evaluation_script__frames), the session is flagged. -
WebGL parameter consistency. Even if you spoof the WebGL
renderer string via
getParameter(UNMASKED_RENDERER_WEBGL), challenge scripts cross-check againstMAX_TEXTURE_SIZE,MAX_RENDERBUFFER_SIZE, andALIASED_LINE_WIDTH_RANGE. These must be internally consistent with the declared GPU. SwiftShader reports impossible parameter combinations for any real GPU.
The patch treadmill. Cloudflare updates their challenge script every 7-14 days. Each update introduces new detection vectors. Maintaining a userland stealth plugin means reverse-engineering minified challenge code on a biweekly cadence—a full-time job that scales to zero vendors.
Runtime-level architecture
Instead of patching after the fact, StableBrowse operates at the Chromium runtime layer. We maintain a modified Chromium build where automation artifacts are structurally absent from the source, not overridden after initialization.
1. CDP channel restructuring.
The Chrome DevTools Protocol uses a bidirectional WebSocket channel between the automation client and the browser process. Challenge scripts detect this channel through timing analysis of the Mojo IPC pipe. Our build restructures the CDP transport to use a Unix domain socket with kernel-level buffering that eliminates the measurable latency signature. The browser process sees the same IPC characteristics as a standalone Chrome instance with DevTools closed.
2. Fingerprint coherence engine.
Each agent session draws from a database of real-device fingerprints collected from opt-in enterprise device fleets. A fingerprint includes:
- GPU renderer string + all WebGL parameter values (cross-validated for consistency)
- Canvas hash (pre-computed from the actual GPU output for that hardware profile)
- AudioContext DynamicsCompressor output (recorded from real hardware)
- Screen resolution,
devicePixelRatio, available fonts - Timezone, locale, language preferences (correlated to GeoIP of the exit proxy)
The key insight is coherence. A fingerprint from an M1 MacBook Pro
must report the Apple M1 GPU renderer, a MAX_TEXTURE_SIZE of 16384,
a screen resolution of 2560x1600 at 2x DPR, and an AudioContext output that
matches the M1's DAC characteristics. Any inconsistency—even one parameter—is
a detection signal.
// Fingerprint coherence validation (simplified)
function validateFingerprint(fp) {
const gpu = GPU_PROFILES[fp.webgl.renderer];
assert(fp.webgl.maxTextureSize === gpu.maxTextureSize);
assert(fp.webgl.maxRenderBuffer === gpu.maxRenderBuffer);
assert(fp.webgl.aliasedLineRange[0] === gpu.aliasedLineMin);
assert(fp.webgl.aliasedLineRange[1] === gpu.aliasedLineMax);
assert(fp.canvas.hash === gpu.expectedCanvasHash[fp.os]);
assert(TIMEZONE_GEO[fp.timezone].includes(fp.proxy.country));
assert(fp.screen.dpr === KNOWN_DPR[fp.device]);
}
3. TLS fingerprint alignment.
JA3 is a method for creating SSL/TLS client fingerprints by hashing the TLS ClientHello message—specifically the SSL version, accepted ciphers, extensions, elliptic curves, and point formats. JA4 extends this with additional HTTP/2 metadata. The problem: headless Chromium's TLS stack produces a different JA3 hash than Chrome stable on the same OS, because the build flags differ.
Our Chromium build uses the exact same BoringSSL configuration and cipher suite ordering as the declared Chrome version. We maintain a CI pipeline that compares our JA3/JA4 output against the official Chrome Canary, Beta, and Stable channels for each platform. Any drift triggers a build failure.
4. HTTP/2 fingerprint matching.
Akamai's bot detection fingerprints the HTTP/2 SETTINGS frame that the client sends on connection establishment. This includes:
SETTINGS_HEADER_TABLE_SIZESETTINGS_ENABLE_PUSHSETTINGS_MAX_CONCURRENT_STREAMSSETTINGS_INITIAL_WINDOW_SIZESETTINGS_MAX_FRAME_SIZESETTINGS_MAX_HEADER_LIST_SIZE- The WINDOW_UPDATE and PRIORITY frames sent after SETTINGS
Standard headless Chrome sends different values from headed Chrome. Our build patches the HTTP/2 session initialization in Chromium's net stack to replicate the exact SETTINGS and priority tree of headed Chrome for the declared version/platform combination.
5. Behavioral injection.
Even with a perfect static fingerprint, synthetic input events are detectable. Real mouse movements follow a minimum-jerk trajectory with Gaussian noise. Scroll events have momentum and deceleration curves that match the OS's scroll physics. Keystroke intervals follow a log-normal distribution unique to human typing.
We maintain a library of recorded human interaction templates
(anonymized, opt-in enterprise users). When an agent performs an
action, the raw CDP Input.dispatchMouseEvent calls are
modulated through these templates. Mouse moves follow Bezier curves
with randomized control points. Scroll events include the OS-level
momentum phase. Keystroke timing is drawn from a per-character
log-normal model trained on real typing data.
Proxy and network identity
IP reputation is the fifth detection layer. Datacenter IPs are flagged by every major anti-bot vendor on sight. Residential proxy pools solve this but introduce new problems: latency variance, session instability, and IP rotation that triggers re-authentication.
StableBrowse maintains dedicated residential IP pools per enterprise client. IPs are pre-warmed with browser traffic to build reputation before agent sessions use them. Each IP is geo-fenced to match the fingerprint's timezone and locale. Session persistence is maintained through sticky IP routing—the same IP is reused for the entire multi-page workflow to avoid mid-session fingerprint changes that trigger Akamai's "impossible travel" heuristic.
Defense in depth. No single stealth technique is sufficient. Detection is a conjunction: challenge scripts flag sessions that fail any check. Our architecture ensures coherence across all layers simultaneously—JS APIs, rendering, TLS, HTTP/2, behavioral, and network identity—because that's how real browsers work.
StableBrowse