The easy tells
Default Puppeteer/Playwright launches with navigator.webdriver === true, window.chrome missing or stripped, an empty navigator.plugins, and an HTTP language list that does not match navigator.languages. Any of these is a single-line check. Stealth plugins patch all of them — but the patches themselves are detectable (see below).
The complete signal inventory
Twelve signals modern anti-bot scripts inspect for headless detection, grouped by how cheap they are to spoof:
| Signal | What's checked | Spoof cost |
|---|---|---|
navigator.webdriver | === true in unmodified Playwright/Puppeteer/Selenium | Trivial JS override (but see toString check below) |
| User-Agent "HeadlessChrome" | Default headless Chrome substring | Trivial — one line |
navigator.plugins | Empty array in default headless | Trivial JS override |
navigator.languages | Length 1 in default headless vs typical 2-3 | Trivial |
| WebGL renderer | SwiftShader / llvmpipe = no GPU | Medium — engine-level patch needed |
| AudioContext fingerprint | ~3 known headless-Linux hashes | Medium — virtual audio device or engine patch |
| Canvas fingerprint | Stable per-machine; headless on Linux produces a small cluster | Hard — PerfectCanvas replay required |
| CDP runtime artifacts | window.cdc_* keys, Runtime.evaluate timing | Hard — undetected-chromedriver patches, breaks on update |
Function.toString() inspection | Every JS-patched method returns its source, not [native code] | Very hard — needs engine-level patch |
| Permissions API quirks | Notification.permission === 'default' in headless on a denied-notifications profile | Medium |
| Mouse + scroll absence | Zero mouse events before click | Medium — synthesize Bezier-curve movement |
| requestAnimationFrame cadence | Headless renders at fixed 60Hz with no vsync jitter | Hard — engine-level |
The cumulative point: a stealth plugin patches 5–10 of these at the JS layer, but the toString() check above defeats every JS-layer patch simultaneously. Engine-level patching (Camoufox, CloakBrowser, PatchRight) is the only durable answer in 2026.
Toolchain status in 2026
Where each stealth toolchain stands against the 12-signal inventory above:
| Tool | Approach | Defeats |
|---|---|---|
| Vanilla Playwright/Puppeteer | None | Nothing — block-grade on first request |
| puppeteer-extra-stealth | JS-layer patches (~17) | Easy tells; loses to toString inspection |
| undetected-chromedriver | Binary + JS patches | Easy + medium tells; loses to toString and Canvas |
| SeleniumBase UC mode | Wraps UC; adds Turnstile auto-click | Same as UC, friendlier API |
| PatchRight | Patches Playwright Python source — patches never exist as JS | Easy + medium + toString. Loses to deep Canvas/WebGL only at enterprise tier. |
| Camoufox | Firefox fork with C++ engine patches + real-machine profile DB | All 12 signals. Hyphenation-dictionary check can still expose it as Firefox. |
| CloakBrowser | Chromium fork with 49 C++ patches | All 12 signals. reCAPTCHA v3 ~0.9 score. |
The dividing line is whether the patches live above or below the JavaScript engine. Above (Playwright/Puppeteer-extra/UC/SeleniumBase) means Function.toString() can read the patch and detect it. Below (PatchRight/Camoufox/CloakBrowser) means the patch is invisible to JS reflection. Production scraping in 2026 picks tools from the bottom of this list.
The medium tells
Headless Chrome ships with a slightly different default font set than desktop Chrome. The HeadlessChrome string appears in the user agent unless overridden. WebGLRenderingContext.getParameter(UNMASKED_RENDERER) returns "Google SwiftShader" on headless instead of a real GPU string. The permissions API returns "denied" for notifications without prompting. Each of these is patched in modern stealth tools.
The hard tells (2026)
The current frontier is meta-detection: anti-bot systems call Function.prototype.toString() on patched native APIs to see if they return function () { [native code] } or the stealth tool's replacement code. playwright-stealth fails this check; Kasada catalogs the patch signatures and blocks on match. The 2026 answer is to patch the browser source (Camoufox, PatchRight) so there is nothing in the JS runtime for toString() to inspect.
