How Puppeteer Detection Actually Works: 12 Signals That Give Bots Away
Automation frameworks inherit a real browser's fingerprint but change it in dozens of observable ways. A field guide to the 12 JavaScript, network, and behavioral signals defenders use to catch Puppeteer and Playwright.
Automation frameworks control real browsers, which means they inherit the browser's fingerprint. A Puppeteer-driven Chrome has the same user agent, the same Chrome version, and the same Chromium build as a human's Chrome. Superficially, they look identical.
But automation frameworks change the browser in dozens of subtle ways — most of them observable from JavaScript. Detection isn't magic. It's a checklist.
1. The navigator.webdriver flag
The simplest and most famous signal. When Chrome runs under an automation framework, navigator.webdriver returns true. It's part of the W3C WebDriver specification and Chromium implements it by default.
Every serious automation project patches this within seconds. Puppeteer-extra-stealth overrides the getter. Playwright users inject Object.defineProperty scripts on new documents. The flag is trivial to hide.
But the fact that it can be trivially hidden is itself a signal. A legitimate Chrome doesn't need to hide anything. When a page checks Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver') and the descriptor looks different from a native browser's — the automation is caught even after the flag is patched.
2. The chrome object anomaly
Legitimate Chrome exposes a global window.chrome object with substantial structure — chrome.runtime, chrome.loadTimes, chrome.csi. Headless Chrome and older Puppeteer setups either omit this object entirely or expose a stripped-down version.
Stealth plugins recreate the object, but the recreation is imperfect. chrome.runtime.onConnect might be present without chrome.runtime.PlatformOs. Function signatures might return objects instead of undefined. Every mismatch is a positive signal.
3. Permissions API inconsistencies
Real browsers return coherent results when queried through the Permissions API. navigator.permissions.query({name: 'notifications'}) should return 'default' if the user hasn't explicitly granted or denied permission.
Headless Chrome returns 'denied' by default, because there's no UI to show a permission prompt. Automation frameworks patch this — but often incorrectly. A common tell: querying an unusual permission like 'clipboard-read' returns a result that doesn't match what real Chrome does in the current version.
4. Plugin and MIME type arrays
navigator.plugins in a real browser returns a PluginArray with entries like the PDF viewer and Chromium PDF plugin. In headless mode, this array is empty.
Stealth plugins add fake entries, but the entries frequently have wrong properties — missing length, wrong description fields, or plugin objects that don't behave like Plugin instances when tested with instanceof.
5. Language and locale mismatches
navigator.language and navigator.languages should match the Accept-Language HTTP header. They should also be consistent with the timezone reported by Intl.DateTimeFormat().resolvedOptions().timeZone.
A bot claiming to be in Kyiv (Europe/Kyiv timezone) but sending Accept-Language: en-US,en;q=0.9 and reporting navigator.language === 'en-US' is possible but statistically unusual. When combined with an IP from a datacenter in Frankfurt, the picture becomes clear.
6. WebGL renderer strings
WebGL2RenderingContext.getParameter(WebGLDebugRendererInfo.UNMASKED_RENDERER_WEBGL) returns the GPU vendor and model. On a real machine, this might be:
ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0)
Headless Chrome running in a container often returns:
ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)
Any GPU string containing SwiftShader or Subzero is a strong bot signal.
7. Screen and viewport anomalies
A real browser window has window.outerWidth and window.outerHeight that account for browser chrome — address bar, tabs, bookmarks bar. The difference between outer and inner dimensions is typically 80–140 pixels vertically.
Headless browsers often have outerHeight === innerHeight because there's no chrome to display. Puppeteer with headless: false fixes this, but many automation setups still run with equal dimensions.
8. Font enumeration
Real users have hundreds of installed fonts, varying by OS, language, and installed applications. Windows 11 with Office installed has 500+ fonts. Automation containers running Alpine Linux have 30.
Font enumeration through document.fonts.check() or offscreen canvas rendering reveals this immediately. A browser reporting itself as Windows 11 Chrome but only exposing 30 fonts is almost certainly automation.
9. Mouse movement entropy
Real users move mice with jitter. Curves are non-linear. Speed varies. Between two clicks, there are typically hundreds of mousemove events.
Automation frameworks synthesize movement — either with straight lines to coordinates, or with programmed curves that lack the microtremors of a human hand. Even Bezier-curve simulations tend to have suspiciously smooth acceleration profiles.
Bot detection systems collect movement traces and score them against a model of human motion. Traces with too-consistent velocity, too-clean curves, or missing microevents are flagged.
10. Timing signatures
Automation frameworks execute JavaScript with different timing characteristics than user-driven browsers. performance.now() timing between events, especially between pointerdown and pointerup, follows a different distribution.
Real click hold times are 50–150ms with high variance. Puppeteer's default click hold is a fixed value — often 30ms or 100ms — with almost no variance. A hundred clicks with identical hold times is definitive automation.
11. TLS fingerprint mismatch
This one bypasses JavaScript entirely. When Puppeteer connects, it uses Chromium's TLS stack — same as a real Chrome. But undici-based or axios-based scrapers that pretend to be Chrome through user agent spoofing use Node.js's TLS stack, which has a distinctly different Client Hello signature (JA4 hash).
A request claiming User-Agent: Chrome/124.0.6367.60 but presenting a Node.js TLS fingerprint is instant confirmation of a non-browser client.
12. CDP protocol leakage
Puppeteer and Playwright communicate with Chrome via the Chrome DevTools Protocol. In some configurations, this protocol leaves detectable artifacts — extra properties on the Runtime object, altered Error.stack formats, or specific console.debug outputs that only appear when a CDP client is attached.
The Runtime.enable command in particular changes how stack traces render. Bots that never trigger errors avoid this signal, but any automation flow that hits an exception (and most do) leaves fingerprints.
Layering these signals
No single signal is proof of automation. A real user with an unusual GPU driver might return a weird WebGL string. A privacy-focused user might have modified navigator.plugins.
The strength of modern detection comes from combining signals — 12 weak positives are stronger than one strong positive, because a real user rarely triggers more than 2–3 anomalies simultaneously.
Detection systems assign each signal a weight and compute a score. Above the threshold, the visitor is treated as automation. The exact threshold and weights are the hardest parts to get right — too strict and legitimate users are blocked; too lenient and sophisticated bots slip through.
The signals themselves are the easy part. Nearly every automation framework leaks all 12. The real engineering is deciding which combinations matter, which are false positives, and how to update the model as automation frameworks adapt.