Anti-bot systems (Cloudflare, Akamai Bot Manager, PerimeterX/HUMAN, DataDome, Kasada) combine multiple independent signals into a composite score. No single evasion fixes detection — a coherent fingerprint requires every layer to agree with every other layer. A residential IP with a datacenter TLS signature, or a spoofed navigator.platform that contradicts the User-Agent, is often a stronger signal than any single raw fingerprint value.
| Vector | Layer | Detection difficulty |
|---|---|---|
| navigator.webdriver / CDP artifacts | JS runtime | low |
| Canvas / WebGL hash | JS runtime | medium |
| AudioContext hash | JS runtime | medium |
| Font list enumeration | JS runtime | medium |
| TLS ClientHello (JA3/JA4) | Network | high |
| HTTP/2 frame ordering | Network | high |
| Mouse/scroll timing entropy | Behavioral | high |
The most trivial and most commonly checked signal. Headless Chrome and CDP-driven browsers leave detectable traces on navigator unless explicitly patched.
// naive server-side or client-side check
if (navigator.webdriver === true) {
flag('automation');
}
if (navigator.plugins.length === 0) {
flag('headless');
}
if (!window.chrome) {
flag('non-standard chrome');
}
Evasion requires patching the property before any page script executes, via Page.addScriptToEvaluateOnNewDocument (CDP) or Playwright's addInitScript. Deleting the property outright is detectable — a real Chrome build never lacks it entirely for headed mode; the correct approach is redefining the getter.
Object.defineProperty(Navigator.prototype, 'webdriver', {
get: () => undefined,
configurable: true
});
navigator.webdriver and stopping there is itself a signature. Detection scripts increasingly check for the absence of properties that a real automation-free environment would have inconsistent behavior around (e.g. permission query timing). Partial patches create a new, smaller anomaly class.
Headless Chrome ships empty navigator.plugins and navigator.mimeTypes by default. These need to be repopulated with objects matching the shape of a real Chrome install (PDF viewer plugin, Native Client, etc.), not just non-zero length — detection scripts check for internal consistency between plugin names and MIME type associations.
Chrome DevTools Protocol itself introduces detectable side effects, independent of any JS-level patching, because the protocol modifies runtime behavior at a level scripts can observe.
Runtime.enable (used by Puppeteer/Playwright by default to receive console/exception events) triggers a detectable timing artifact in Error.captureStackTrace and exposes extra properties on thrown errors. Sites check for the presence of CDP-only binding artifacts injected into the page context.Runtime.consoleAPICalled path retains references that can be detected by checking whether console methods have been wrapped or whether their toString() output matches native code.page.exposeFunction in Puppeteer/Playwright injects a named function on window that a fingerprinting script can iterate for and find.Runtime.enable side channel cannot be fixed by patching JS properties — it requires either not enabling that domain (losing console/exception capture) or using a patched CDP implementation that suppresses the artifact. Puppeteer-extra-plugin-stealth's runtime.enable evasion module addresses this specifically; verify it's active rather than assuming stealth plugins cover it by default.
Rendering text or shapes to a <canvas> element and hashing the pixel output produces a value that varies by GPU, OS font rendering, and driver — stable per machine, distinct across machines.
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillText('fingerprint-probe-🔒', 2, 2);
const hash = hashCode(canvas.toDataURL());
Two evasion strategies exist, with different tradeoffs:
Hooking toDataURL / getImageData to add a small per-session, per-pixel noise value. Must be seeded consistently within a session (same page reload → same fingerprint) but vary across sessions/profiles.
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
CanvasRenderingContext2D.prototype.getImageData = function(...args) {
const imageData = origGetImageData.apply(this, args);
for (let i = 0; i < imageData.data.length; i += 4) {
const noise = (sessionSeed * (i % 97)) % 3 - 1;
imageData.data[i] = clamp(imageData.data[i] + noise, 0, 255);
}
return imageData;
};
Running headless Chrome with a consistent, real GPU renderer (via --use-gl=angle and a fixed ANGLE backend) so the canvas output matches a common, high-population fingerprint rather than a rare or software-rendered one. This avoids modification detection since no hooks are present, but requires infrastructure control over the rendering pipeline.
--disable-gpu combined with SwiftShader) produces a canvas hash cluster that is heavily overrepresented in bot traffic and underrepresented in real user traffic. Matching a common real-GPU fingerprint distribution beats hiding the fact that rendering happened at all.
WEBGL_debug_renderer_info exposes the actual GPU vendor/renderer strings (e.g. ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device...))), which immediately identifies software-rendered headless environments.
const getParameterProxyHandler = {
apply(target, ctx, args) {
const param = args[0];
if (param === 37445) return 'Google Inc. (NVIDIA)'; // UNMASKED_VENDOR_WEBGL
if (param === 37446) return 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0)';
return Reflect.apply(target, ctx, args);
}
};
WebGLRenderingContext.prototype.getParameter = new Proxy(
WebGLRenderingContext.prototype.getParameter, getParameterProxyHandler
);
Rendering an OscillatorNode through a DynamicsCompressorNode and reading back the output buffer produces sub-sample floating-point variance tied to the audio stack's floating-point implementation — stable across page loads on the same machine, distinct across machines/OSes.
audio-noise.jsconst origCopyFromChannel = AudioBuffer.prototype.copyFromChannel;
AudioBuffer.prototype.copyFromChannel = function(destination, channelNumber, startInChannel) {
origCopyFromChannel.call(this, destination, channelNumber, startInChannel);
for (let i = 0; i < destination.length; i++) {
destination[i] += (Math.random() * 2 - 1) * 1e-7;
}
};
The noise magnitude matters: too large a perturbation shifts the hash into an implausible cluster (detectable as an outlier vs. the known distribution of real hardware DSP noise); too small leaves the original fingerprint largely intact.
Measuring text width/height across a list of candidate font names against fallback fonts reveals which fonts are installed — OS and locale-specific font sets are a strong signal. A Linux CI box running Chrome typically has a font set that no real Windows/macOS consumer machine would ever exhibit.
fonts-liberation, ttf-mscorefonts-installer on a Linux box impersonating Windows) rather than trying to spoof measurement APIs, which is fragile and detectable via inconsistency across multiple measurement methods (canvas text metrics vs. CSS font-fallback probing vs. document.fonts.check()).
Values that must agree with each other and with the claimed device class:
| Property | Consistency requirement |
|---|---|
screen.width / screen.height | Must match a real, commonly-sold display resolution for the claimed device class |
window.devicePixelRatio | Must be plausible for the resolution (e.g. 2 for a Retina-class laptop, not for a claimed 1080p external monitor) |
navigator.hardwareConcurrency | Must not report absurd values (128) alongside a mobile User-Agent |
navigator.deviceMemory | Must fall within the small discrete set Chrome actually reports (0.25–8) |
Intl.DateTimeFormat().resolvedOptions().timeZone | Must match the IP geolocation's timezone, not the scraping server's |
navigator.language / Accept-Language header | Must match each other exactly, including regional variant ordering |
This layer sits below the JS runtime entirely and is the hardest to spoof from a standard HTTP client, because it depends on the TLS library's actual ClientHello construction, not anything an HTTP client's API exposes for configuration.
requests (via OpenSSL) produces a JA3 hash trivially distinguishable from real Chrome (via BoringSSL), regardless of User-Agent header spoofing.Mitigation requires a client that replicates the exact TLS stack behavior of a real browser — curl-impersonate, tls-client (Go), or actually driving a real browser engine (Playwright/Puppeteer/CDP) rather than a bare HTTP client. There is no header-level fix; the ClientHello is constructed below the application layer.
requests or axios session while leaving the underlying TLS stack untouched is one of the most common and most trivially detected mismatches in scraping infrastructure. The header claims one client; the ClientHello proves another.
Real browsers send headers in a fixed, engine-specific order and with specific casing (e.g. Chrome's fetch always orders sec-ch-ua headers before accept). Many HTTP libraries alphabetize or reorder headers by default, and some normalize casing. Matching order requires either a browser-driven request or a library that permits raw header ordering control (not all do).
Once static fingerprints are consistent, higher-tier anti-bot products (DataDome, PerimeterX) score interaction patterns:
page.mouse.move() calls in a straight line or with uniform velocity are flagged.page.click() and page.type() calls fire at suspiciously uniform or suspiciously fast intervals.scrollIntoView) versus gradual scroll with variable-speed wheel events.visibilitychange or window blur events that a real multi-tab user session generates constantly.Libraries like ghost-cursor generate Bezier-curve mouse paths with randomized intermediate waypoints to approximate this; injecting randomized delay distributions (log-normal, not uniform) between actions approximates human timing better than fixed sleep() calls.
| Tool | Approach | Maintenance state |
|---|---|---|
| puppeteer-extra-plugin-stealth | Modular JS patches (webdriver, chrome runtime, plugins, WebGL, iframe.contentWindow) | Community-maintained, patches lag behind new detection methods |
| playwright-extra + stealth | Same patch set as puppeteer-extra, ported to Playwright | Smaller community than puppeteer-extra |
| undetected-chromedriver | Patches the chromedriver binary itself to avoid CDP-detectable strings, rather than JS-layer patching | Actively maintained, Selenium-based |
| nodriver | Successor to undetected-chromedriver; drives Chrome via CDP directly without a chromedriver binary layer at all | Actively maintained |
| camoufox | Firefox fork with fingerprint parameters (fonts, WebGL, navigator props) built into the C++ layer rather than JS-injected | Actively maintained |
Function.prototype.toString on a patched method won't return the exact native code string Chrome ships, unless the patch also spoofs toString(). Binary-level patches (undetected-chromedriver, camoufox) avoid this class of detection entirely, at the cost of needing a maintained fork per browser version.
Before deploying, cross-check that the following all agree with a single claimed device/browser/location combination:
navigator.platform, and Sec-CH-UA client hints all describe the same OS/browser/versionIntl.DateTimeFormat), Accept-Language, and IP geolocation all agreedevicePixelRatio match a real, commonly-sold deviceRuntime.enable artifacts, exposed function names) are suppressed, not just navigator.webdriver