Fingerprint surface overview

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.

VectorLayerDetection difficulty
navigator.webdriver / CDP artifactsJS runtimelow
Canvas / WebGL hashJS runtimemedium
AudioContext hashJS runtimemedium
Font list enumerationJS runtimemedium
TLS ClientHello (JA3/JA4)Networkhigh
HTTP/2 frame orderingNetworkhigh
Mouse/scroll timing entropyBehavioralhigh

The most trivial and most commonly checked signal. Headless Chrome and CDP-driven browsers leave detectable traces on navigator unless explicitly patched.

detection.js
// 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.

init-script.js — patch navigator.webdriver
Object.defineProperty(Navigator.prototype, 'webdriver', {
  get: () => undefined,
  configurable: true
});
Warn Patching only 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.

Plugin and mimeType arrays

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.

CDP and automation leaks

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.

Danger Detection of the CDP 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.

Canvas fingerprinting

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.

fingerprint-source.js — typical canvas probe
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:

1. Deterministic noise injection

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.

canvas-noise.js
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;
};

2. GPU/render-path normalization

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.

Tip Software rendering (--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 fingerprinting

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.

webgl-spoof.js
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
);
Warn Spoofed vendor/renderer strings must correspond to a real, existing hardware pairing and must be internally consistent with the reported OS and User-Agent (e.g. don't pair an NVIDIA desktop GPU string with a User-Agent claiming macOS on Apple Silicon). Cross-referencing GPU string against OS/platform is a standard check in commercial anti-bot rule sets.

AudioContext fingerprinting

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.js
const 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.

Font enumeration

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.

Note Font evasion is primarily an infrastructure problem, not a JS-patching problem: install the font packages matching the OS/UA being impersonated (e.g. 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()).

Screen and hardware consistency

Values that must agree with each other and with the claimed device class:

PropertyConsistency requirement
screen.width / screen.heightMust match a real, commonly-sold display resolution for the claimed device class
window.devicePixelRatioMust be plausible for the resolution (e.g. 2 for a Retina-class laptop, not for a claimed 1080p external monitor)
navigator.hardwareConcurrencyMust not report absurd values (128) alongside a mobile User-Agent
navigator.deviceMemoryMust fall within the small discrete set Chrome actually reports (0.25–8)
Intl.DateTimeFormat().resolvedOptions().timeZoneMust match the IP geolocation's timezone, not the scraping server's
navigator.language / Accept-Language headerMust match each other exactly, including regional variant ordering

TLS/JA3 and header 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.

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.

Danger Setting a Chrome User-Agent header on a 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.

Header ordering and casing

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

Behavioral signals

Once static fingerprints are consistent, higher-tier anti-bot products (DataDome, PerimeterX) score interaction patterns:

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.

Stealth framework comparison

ToolApproachMaintenance state
puppeteer-extra-plugin-stealthModular JS patches (webdriver, chrome runtime, plugins, WebGL, iframe.contentWindow)Community-maintained, patches lag behind new detection methods
playwright-extra + stealthSame patch set as puppeteer-extra, ported to PlaywrightSmaller community than puppeteer-extra
undetected-chromedriverPatches the chromedriver binary itself to avoid CDP-detectable strings, rather than JS-layer patchingActively maintained, Selenium-based
nodriverSuccessor to undetected-chromedriver; drives Chrome via CDP directly without a chromedriver binary layer at allActively maintained
camoufoxFirefox fork with fingerprint parameters (fonts, WebGL, navigator props) built into the C++ layer rather than JS-injectedActively maintained
Note JS-injected patches (puppeteer-extra-stealth) are detectable in principle by checking whether native functions have been overridden — 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.

Consistency checklist

Before deploying, cross-check that the following all agree with a single claimed device/browser/location combination:

Tip Test against your own fingerprint using EFF Cover Your Tracks or Fingerprint.com's demo before running against a production target — these surface the same signal categories commercial anti-bot vendors use, and let you catch inconsistencies before they're scored against a real detection budget.