← All articles

Puppeteer stealth plugin, what it patches and what it never tried to fix

4 min read puppeteerautomationguide

If your automation stack runs on Puppeteer, puppeteer-extra-plugin-stealth is probably already sitting in your package.json. It is a sensible default: a set of small patches for the JS-visible properties a stock Chromium exposes that a human's browser does not. It was never an identity system, and treating it as one is where scripts that worked for months start failing in production without an obvious cause.

What it actually patches

The plugin ships as a list of named evasion modules, each targeting one property: navigator.webdriver, chrome.runtime, chrome.app, chrome.csi, chrome.loadTimes, iframe.contentWindow, media.codecs, navigator.languages, navigator.permissions, navigator.plugins, sourceurl, user-agent-override, and webgl.vendor among others. Each module runs before page scripts do and rewrites a getter so the property reads the way it does in a headful, human-driven Chromium instead of the automation defaults. It is a patch list, applied per page load, targeted at specific properties.

The maintenance problem

Every module in that list is a bet on what "normal" looks like in the current Chromium build. Headless defaults, permissions API behaviour, and the exact shape of chrome.runtime have all shifted across Chromium releases, so an evasion module written against one version can be stale, or wrong, against the next. A patch that overcorrects is its own signature: a detector does not need to catch automation directly if it can instead recognise the specific property shape a stealth plugin leaves behind. Keeping the module list correct means re-checking it against every Chromium release the plugin claims to support, and a project with a slow release cadence relative to Chromium's own is a real operational risk for anyone depending on it without watching the changelog.

Three things it was never scoped to solve

Identity that persists across runs. The plugin patches properties inside the current process. It does not generate or store a canvas hash, WebGL renderer string, font list, or audio fingerprint tied to a profile directory. Point it at a fresh userDataDir, or the same one after a Chromium upgrade, and the underlying device signature can change while the patched properties stay identical. Run several identities on one machine and they all report the same hardware fingerprint under different cookies, which is easier for a risk engine to cluster than no stealth patching at all.

Proxy binding that carries timezone and locale. page.authenticate() and --proxy-server= route the traffic. Neither touches Intl.DateTimeFormat().resolvedOptions().timeZone or navigator.language, so a session can route through a Berlin exit IP while the browser still reports a US timezone and locale. That mismatch is exactly what timezone-and-IP consistency checks are built to catch, and no evasion module in the stealth plugin addresses it.

A WebAuthn authenticator for passkeys. Nothing in the plugin touches WebAuthn. Puppeteer's CDP WebAuthn domain can add a virtual authenticator for a session, but nothing persists the credential with the profile, so any account that moves to passkey sign-in becomes unreachable the next time the script runs.

Checks worth running on your current setup

A walkthrough of the fingerprint side of this check is in Browser fingerprint consistency check.

Where the stealth plugin is the right answer

One identity, one machine, targets that do not fingerprint aggressively, accounts that have not moved to passkeys: the plugin plus a persistent userDataDir covers that case and costs nothing beyond the install. Swapping it for anything else at that scale is overhead for a problem you do not have.

Where AntiBrow fits, and the real migration cost

AntiBrow generates the fingerprint once per profile and applies it in the browser engine, below the JS layer, so it persists across restarts instead of drifting with the machine. Binding a proxy to a profile derives timezone, locale, and geolocation from the exit IP automatically. Each profile carries its own virtual authenticator, so a passkey enrolled once is captured and replayed at the next sign-in. Local profiles are unlimited on every plan, including Free, with no card and no expiry.

Say the cost plainly: launch() returns a standard Playwright Page and BrowserContext, not Puppeteer's. Code built around page.$, page.$eval, ElementHandle, or page.setRequestInterception does not run unmodified against a Playwright Page. Locators, auto-waiting, and request interception work differently enough that a Puppeteer-shaped test suite and helper library face a real rewrite, not a one-line swap. If your codebase is deep in Puppeteer idioms, budget for that migration honestly before you start it. Teams already on Playwright hit none of this; see Playwright persistent context profiles for the identical gaps covered from that side.

Test it yourself

Create a free profile, bind it to any proxy, and repeat the canvas and timezone checks above against it instead of a plain userDataDir. The SDK reference has the launch() call and profile options.

Try it on the free tier.

Unlimited local profiles, no credit card. Check it against the detectors yourself.