Is My Puppeteer Bot Detectable? How to Find Out
You've built a scraper, a testing pipeline, or an automation workflow with Puppeteer. It works locally. But then it hits a real website and gets blocked, served a CAPTCHA, or silently fed bad data.
The problem is usually your browser fingerprint. Out of the box, Puppeteer leaves signals that detection systems flag. Here's what leaks and how to find out if your bot is detectable.
What makes Puppeteer detectable
1. The navigator.webdriver flag
The WebDriver specification requires automated browsers to set navigator.webdriver to true. Every detection system checks this.
navigator.webdriver // true in automated browsers
puppeteer-extra-plugin-stealth patches this to false, but the patch can be detected by inspecting the property descriptor. A real browser defines webdriver as a getter on Navigator.prototype, not as a data property on navigator.
2. Missing browser plugins
Since Chrome 90+, navigator.plugins returns a standardized list of 5 PDF-related entries in regular Chrome. In old headless mode (headless: 'shell'), this list is empty. The new headless mode (headless: true, the default since Puppeteer v22) returns the same 5 entries as regular Chrome.
navigator.plugins.length // 0 in old headless shell, 5 in Chrome and new headless
3. Chrome runtime objects
Real Chrome exposes window.chrome with runtime, loadTimes, and csi properties. Automated Chrome may omit these or provide incomplete stubs that are missing expected methods.
4. WebGL renderer strings
Your GPU renderer string reveals the graphics hardware. Headless Chrome running without GPU access typically reports Google SwiftShader, a software renderer. Detection systems flag software renderers because regular users almost never have them.
5. Permission inconsistencies
Real browsers return "prompt" for Notification.permission by default. Headless Chrome returns "denied". The Permissions.query() API behaves similarly: headless returns "denied" for permissions that real Chrome returns "prompt" for.
6. Screen and window dimensions
Headless browsers often have window.outerHeight === window.innerHeight (no browser toolbar offset), or report screen.availWidth === screen.width (no taskbar offset).
What puppeteer-extra-plugin-stealth fixes, and what it doesn't
puppeteer-extra-plugin-stealth patches the most commonly checked signals: navigator.webdriver, navigator.plugins, chrome.runtime, and some Permissions responses.
What it typically doesn't cover:
- Worker scope leaks. Web Workers have their own
navigatorobject. Patches applied to the main frame don't carry over to workers. - Prototype chain integrity. Detection scripts verify that
navigator.pluginsis a realPluginArraywith the correct prototype, not a plain object. - Function toString integrity. Native browser functions return
"function name() { [native code] }"when.toString()is called. Patched functions may return different strings, orFunction.prototype.toStringitself may be modified. - Iframe consistency. Some detectors create a same-origin iframe (e.g.,
about:blank) and compare its unmodified environment with the main frame. - Stack trace analysis. Error stack traces can reveal Puppeteer's injection scripts.
How to test your setup
The quickest way is to open https://stealthcheck.io/test in your Puppeteer browser in headed mode (headless: false) and review the results. The test runs automatically and shows a score with a detailed issue breakdown. No account needed, but the free test is limited to 10 checks per day. For CI or frequent testing, use a monitoring profile instead.
For programmatic access, use a monitoring profile token. The lightweight check page at /check/{token} exposes results on window.__stealthcheck:
import puppeteer from 'puppeteer-extra'
import StealthPlugin from 'puppeteer-extra-plugin-stealth'
puppeteer.use(StealthPlugin())
const browser = await puppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto(process.env.STEALTH_CHECK_URL)
// The check page adds a .done class when analysis is complete
await page.waitForSelector('.done', { timeout: 30000 })
const result = await page.evaluate(() => window.__stealthcheck)
console.log(`Score: ${result.score}/100`)
console.log(`Issues: ${result.issues.length}`)
await browser.close()
Common fixes
-
Use the default headless mode. Since Puppeteer v22,
headless: trueuses the new headless mode (same Chrome binary as headed). The old headless shell (headless: 'shell') has more detectable artifacts. -
Launch with a real user data directory. Pass
--user-data-dir=/path/to/profileinargsto load real extensions, plugins, and preferences. -
Set realistic viewport. Use
page.setViewport()with common resolutions like 1920x1080. -
Fix timezone and locale. Use
page.emulateTimezone('America/New_York')and setargs: ['--lang=en-US']sonavigator.languagematches your configuration. -
Override WebGL renderer. Pass
--use-gl=angleinargsto get an ANGLE renderer string instead of SwiftShader.
The detection arms race
Bot detection is adversarial. Detection systems add new signals and change scoring over time. What works today may not work after a Chrome or Puppeteer update. Testing regularly, especially after dependency updates, is the most reliable way to catch regressions.
Further reading
- Run a free stealth test to see your current score
- Playwright stealth detection for Playwright-specific guidance
- How detection systems catch fingerprint spoofing
Test your browser stealth for free
Run a free fingerprint test and see exactly what detection systems see.