The easy tells
These are the giveaways a detector can catch with a single line of JavaScript. By default, Puppeteer and Playwright launch with navigator.webdriver === true (the automation flag is on), window.chrome missing or stripped out, an empty navigator.plugins list, and an HTTP language header that does not match what navigator.languages reports inside the page. 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 (fake convincingly):
| 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 |
Here is the key idea. A stealth plugin patches 5–10 of these from inside JavaScript — but the Function.toString() check listed above defeats every JS-layer patch at once, because in JavaScript you can ask any function to print its own source code. A real browser API prints [native code]; a patched one prints the replacement, exposing the patch. Patching below JavaScript, inside the browser's own C++ engine (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 simple: are the patches 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 baked into the browser binary and is invisible to JavaScript inspection. Production scraping in 2026 picks tools from the bottom of this list.
The medium tells
These take a little more work to catch than a one-line flag check. Headless Chrome ships with a slightly different default set of fonts than desktop Chrome. The HeadlessChrome string appears in the user agent unless you override it. Asking the graphics API for its hardware name, WebGLRenderingContext.getParameter(UNMASKED_RENDERER), returns "Google SwiftShader" (a software renderer, i.e. no real GPU) on headless instead of a genuine GPU name. The permissions API returns "denied" for notifications without ever prompting the user. Each of these is patched in modern stealth tools.
The hard tells (2026)
The current frontier is meta-detection — catching the act of patching itself rather than any one fingerprint. Anti-bot systems call Function.prototype.toString() on patched native APIs to see whether they return function () { [native code] } (a genuine browser function) or the stealth tool's replacement code (a giveaway). playwright-stealth fails this check; Kasada catalogs the patch signatures and blocks on a match. The 2026 answer is to patch the browser source itself (Camoufox, PatchRight) so there is nothing in the JavaScript runtime for toString() to inspect.
