Back to blog
·4 min read

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 navigator object. Patches applied to the main frame don't carry over to workers.
  • Prototype chain integrity. Detection scripts verify that navigator.plugins is a real PluginArray with 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, or Function.prototype.toString itself 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

  1. Use the default headless mode. Since Puppeteer v22, headless: true uses the new headless mode (same Chrome binary as headed). The old headless shell (headless: 'shell') has more detectable artifacts.

  2. Launch with a real user data directory. Pass --user-data-dir=/path/to/profile in args to load real extensions, plugins, and preferences.

  3. Set realistic viewport. Use page.setViewport() with common resolutions like 1920x1080.

  4. Fix timezone and locale. Use page.emulateTimezone('America/New_York') and set args: ['--lang=en-US'] so navigator.language matches your configuration.

  5. Override WebGL renderer. Pass --use-gl=angle in args to 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

Test your browser stealth for free

Run a free fingerprint test and see exactly what detection systems see.