Every session Puppeteer drives is talking to Chrome over the Chrome DevTools Protocol, and CDP usage is detectable from inside the browser. That remains the most reliable detection signal in 2026, and it is the bind stealth tooling cannot escape: puppeteer-extra-plugin-stealth, the most-used patch on the most-used Headless Chrome automation library, defeats naive bot detection but still leaks at layers that cannot be fixed without rewriting how Puppeteer talks to Chrome.
For the Selenium and Playwright equivalents, see Selenium bot detection and Playwright bot detection. For what all the frameworks share, headless browser detection; for the broader frame, bot detection.
What “Puppeteer bot” means
Puppeteer is a Node.js library by the Chrome team for driving Chromium browsers programmatically. It runs Chrome (headless or headed), connects to it over the Chrome DevTools Protocol (CDP), and exposes a high-level API for navigating, clicking, typing, evaluating scripts, and reading the resulting state. Everything below applies to mainline puppeteer and the puppeteer-extra fork ecosystem built on it; the same stack also underpins many AI agents that drive a browser, a population covered separately in AI agent detection.
Two architectural choices make Puppeteer easier to detect than other frameworks:
- CDP is the only transport. Selenium has WebDriver BiDi as an alternative; Playwright supports multiple browsers and protocols. Puppeteer is CDP all the way down, and CDP has its own observable side effects inside the browser.
- The default is headless. Headless Chrome has historically leaked differently from headed Chrome, even on the same binary. Recent versions narrow the gap, but the gap has not closed.
Detection therefore concentrates on CDP artefacts and headless-mode artefacts, while operators who want to avoid it mostly run puppeteer-extra-plugin-stealth and hope for the best.
The default-Puppeteer tells
Out of the box, Puppeteer launches Chrome with a configuration that announces itself loudly. The standard checks:
1. The User-Agent
Default Puppeteer in headless mode reports a User-Agent containing HeadlessChrome instead of Chrome:
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36
A Sec-CH-UA Client Hint with "HeadlessChrome" brand entries gives the same information. Either is sufficient on its own.
2. navigator.webdriver
Set to true by default. The W3C WebDriver spec mandates this for automation-controlled browsers.
3. navigator.plugins.length
Default headless Chrome reports zero plugins. Headed Chrome reports a small list (PDF viewer, Chrome PDF Plugin, Native Client). Bot scripts check navigator.plugins.length === 0 && navigator.platform !== 'iPhone' and treat the result as a strong tell.
4. navigator.languages
Default headless Chrome returns an empty array ([]). Real Chrome returns the user’s configured language list (['en-US', 'en']).
5. window.chrome.runtime
Real Chrome on the desktop exposes window.chrome.runtime as an object with API methods. Default headless Chrome leaves it undefined.
6. WebGL renderer
Headless Chrome typically reports the GPU renderer as ANGLE (Google, SwiftShader, ...) or similar, because the headless mode uses SwiftShader for software rendering. Real users almost never have that string.
7. Permission state
navigator.permissions.query({name: 'notifications'}) returns a permission state that differs between real and headless Chrome under specific configurations. This was one of the earliest tells documented in 2017 and still works against unpatched headless setups.
8. Outer dimensions
window.outerWidth === 0 && window.outerHeight === 0 is true in some headless configurations because there is no window chrome.
A vanilla Puppeteer session trips at least four of these checks. Any one of them is enough to score the session as automation with high confidence.
What puppeteer-extra-plugin-stealth patches
The puppeteer-extra-plugin-stealth package by berstend (GitHub: puppeteer-extra evasions) ships a bundle of evasion modules. The list as of recent versions includes:
chrome.app(defineswindow.chrome.appto match real Chrome)chrome.csi(defineswindow.chrome.csi)chrome.loadTimes(defineswindow.chrome.loadTimes)chrome.runtime(defineswindow.chrome.runtime)defaultArgs(passes Chrome flags that suppress automation-related artefacts)iframe.contentWindow(patches a tell where iframes reportnullinstead of a Window)media.codecs(patchesMediaSource.isTypeSupportedandHTMLMediaElement.canPlayTypeto return Chrome-real codec values)navigator.hardwareConcurrency(sets a plausible value)navigator.languages(sets['en-US', 'en'])navigator.permissions(patchesnotificationspermission state)navigator.plugins(synthesizes a plausible plugins array)navigator.vendor(sets'Google Inc.')navigator.webdriver(deletes the property)sourceurl(cleans the//# sourceURL=__puppeteer_evaluation_script__artefact in evaluated scripts)user-agent-override(stripsHeadlessChromefrom the UA)webgl.vendor(overrides the WebGL renderer and vendor)window.outerdimensions(sets non-zero outer dimensions)
The list covers the named tells but not unnamed ones, and none of it changes how Puppeteer talks to Chrome.
CDP detection: the cheat that still works
The single most useful Puppeteer detection in 2026 is the CDP Runtime.enable check. The story is worth telling because it illustrates how detection works in modern browsers.
When Puppeteer connects to Chrome, it issues Runtime.enable over CDP to receive Runtime.consoleAPICalled events when scripts call console.log. This is necessary for Puppeteer’s page.on('console', ...) API to work, so most Puppeteer code paths trigger it.
For years, an under-documented side effect of Runtime.enable was that when a page called console.debug with a getter that threw an exception, the CDP layer would serialise the error for the DevTools preview. The serialisation triggered the getter on error.stack, which the bot detection script could intercept (Rebrowser: Runtime.Enable CDP detection). The protocol-level story, including the CDP-minimal drivers that followed, is covered in full in CDP detection.
let detected = false;
const obj = {};
Object.defineProperty(obj, 'stack', {
get() { detected = true; return 'fake stack'; }
});
const err = new Error('detector');
Object.defineProperty(err, 'stack', {
get() { detected = true; return 'fake stack'; }
});
console.debug(err);
// If CDP Runtime is enabled, .stack was accessed during serialization.
// detected === true
That detection became the standard tell for CDP attachment regardless of which automation library was driving the browser. Even stealth-patched Puppeteer triggered it, because the stealth patches did not change how Puppeteer talked to CDP.
As of August 2025, a Chrome change altered the underlying behavior, weakening this particular check. DataDome and others published reports tracking the shift (Security Boulevard: Why a classic CDP bot detection signal suddenly stopped working). As of mid-2026, newer variants of the check exploit slightly different CDP side effects but follow the same principle: anything that observes a difference between “DevTools is attached” and “DevTools is not attached” is a viable detection.
Tools like nodriver and Rebrowser-Patches avoid Runtime.enable entirely and use alternative CDP patterns. These defeat the specific Runtime.enable check but introduce their own artefacts and are far less feature-complete than mainstream Puppeteer.
New Headless Chrome and what it changes
Chrome 109 (January 2023) introduced “new headless mode,” which runs the same Chromium binary as headed Chrome but with the UI suppressed. The point was to make headless mode behave more like a real browser for legitimate testing use cases.
The side effect is that new headless mode looks much more like real Chrome to fingerprinting checks. The User-Agent no longer contains HeadlessChrome if the operator opts in. The plugins array is populated. The chrome.runtime object is real. The WebGL renderer reflects the actual hardware.
For bot detection, this means the JS environment checks no longer catch new-headless-mode sessions reliably. The detection has to lean on CDP artefacts, behavioral absence, network signals, and timing-based checks. DataDome wrote a detailed treatment of this transition (DataDome: How New Headless Chrome & the CDP Signal Are Impacting Bot Detection).
In practical terms, new-headless-mode Puppeteer with puppeteer-extra-stealth is genuinely harder to catch than 2023-era headless Puppeteer was. It is still catchable, but the defense has had to move one layer deeper.
Practical detection in 2026
The signal hierarchy for detecting Puppeteer in production:
-
Cheap server signals.
- JA4 fingerprint inconsistent with claimed Chrome UA.
- HTTP/2 SETTINGS frame from a Node.js client (Puppeteer ships its own Chromium but the TLS layer is Chrome’s, so this is less useful than it used to be).
- IP from a hosting ASN paired with a Chrome UA.
-
JS environment checks.
- The whole battery of named tells (webdriver, plugins, languages, chrome.runtime, etc.).
- WebGL renderer matching
SwiftShaderorANGLE (Google, ...). - Permission state inconsistencies.
-
CDP attachment detection.
- Runtime.enable side effects (
console.debug+ thrown getter). - Other CDP-attached timing tells.
- Patterns specific to
puppeteer-extra-plugin-stealthevaluateOnNewDocument scripts.
- Runtime.enable side effects (
-
Behavioral absence.
- Mouse, scroll, focus, key events at zero or with synthetic-looking distributions.
-
Cross-checks.
- User-Agent says Chrome on Windows. The TLS fingerprint says Chrome on Linux. The IP says Hetzner. The behavior says automation.
Production detectors run all five and combine them probabilistically, since no single check is reliable on its own but the joint distribution is very hard to fake.
How Foil detects Puppeteer
Foil’s classification surface includes framework.puppeteer and stealth variants such as framework.puppeteer.stealth. The classification combines:
- Server-side network and TLS signals.
- All the named JS environment checks above.
- CDP attachment detection (multiple variants, updated as Chrome evolves).
- Behavioral snapshot evaluation.
When a session classifies as a Puppeteer-driven Chrome, the verdict includes which signals fired so the application can decide whether to allow (testing, legitimate use), throttle, challenge, or block.
Further reading
- berstend, puppeteer-extra-plugin-stealth (source and evasion list): github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions
- DataDome, Detecting Headless Chrome’s Puppeteer Extra Stealth Plugin: datadome.co/bot-management-protection
- DataDome, How New Headless Chrome & the CDP Signal Are Impacting Bot Detection: datadome.co/threat-research
- Rebrowser, How to fix Runtime.Enable CDP detection: rebrowser.net/blog
- deviceandbrowserinfo, How to detect (modified, headless) Chrome instrumented with Puppeteer (2024 edition): deviceandbrowserinfo.com