How puppeteer-extra-plugin-stealth works, patch by patch
Open the evasions directory of puppeteer-extra and you find something closer to a museum than a library. Each subfolder fixes one specific way that headless Chrome used to answer a JavaScript question differently from the Chrome on your laptop. navigator.webdriver. chrome.runtime. navigator.permissions. navigator.plugins. webgl.vendor. iframe.contentWindow. Nineteen folders in total, most of them a few dozen lines, each one a small argument about what a real browser looks like. Read them in order and you are reading the history of a detection war told from the side that was losing it one property at a time.
The plugin is a bundle. You require puppeteer-extra-plugin-stealth, it pulls in the evasion modules, and each module injects a patch into every new document before the page’s own scripts run. That timing is the whole trick: the patch has to win the race against the detection script, and it has to leave no fingerprints of its own. This post walks the individual patches, says what leak each one was built to close, shows the mechanism in enough detail to understand it, and is honest about the places where the patch itself becomes the new tell. We start with the cheapest signal a page can read, the webdriver flag, then move through the fabricated window.chrome surface, the permissions contradiction, the synthetic plugin arrays, the spoofed GPU strings, and the iframe edge case. A closing section covers what the new headless mode did to the whole exercise, because a lot of these patches now fix problems that Chrome itself already fixed.
The injection model, and why timing is everything
Every evasion shares one delivery mechanism. The plugin calls page.evaluateOnNewDocument (Puppeteer’s name for the CDP method that registers a script to run on each fresh document, before any page script executes). The patch runs first. By the time a detection tag on the page calls navigator.webdriver or navigator.permissions.query, the property has already been redefined or the method already wrapped.
That ordering guarantee is strong but not total. The patch runs in the page’s main world, the same JavaScript context the site’s code runs in, which means anything the patch does to a prototype or a getter is visible to that code. A patch that swaps a native method for a JavaScript function leaves a JavaScript function where a native one should be. So the modules do two jobs at once. The first job is the spoof: make the value or behaviour match real Chrome. The second job, harder and more interesting, is to make the spoof itself invisible. Most of the cleverness in the codebase lives in the second job.
*The patch wins the race by construction, but it runs in the same world the detection code reads from, so the spoof has to hide itself as well as the original value.*The shared helpers live in _utils. Three of them carry most of the weight. makeNativeString builds the string function name() { [native code] } for any function name, so a wrapped method can be made to lie about its own source. patchToString installs a proxy around Function.prototype.toString so that calling .toString() on a patched function returns that native-looking string instead of the actual JavaScript body. And stripProxyFromErrors wraps every proxy trap in a try/catch that rewrites the error’s stack, deleting the lines that would reveal a Proxy or the helper file in a thrown exception’s call stack. Those three are the difference between a patch that survives a toString probe and one that does not. We will keep coming back to them.
navigator.webdriver: the flag everyone checks first
The cheapest tell in the entire catalog is navigator.webdriver. The property is part of the W3C WebDriver specification, which mandates that a user agent under automation expose a read-only webdriver flag that returns true. Chrome sets it whenever it was launched under automation. A detection script reads one boolean and it is done, so this is the first thing any anti-bot tag checks and the first thing any stealth tool patches.
The interesting part is how the patch changed over Chrome versions, because the navigator.webdriver/index.js module carries both approaches and picks between them by version. On older Chrome, before 88.0.4291.0, the property sat on the Navigator prototype and could simply be removed. The module does exactly that: delete Object.getPrototypeOf(navigator).webdriver. Deleting the property off the prototype means a lookup walks past it and resolves to undefined, which is what a normal browser without automation returns.
From Chrome 88 onward the cleaner fix moved down into the browser launch. Chromium exposes a Blink feature called AutomationControlled, and disabling it with the command-line flag --disable-blink-features=AutomationControlled stops Chrome from ever setting the property in the first place. The module appends that flag to the launch arguments. This is the better patch by far, because it fixes the value at the source rather than overwriting it in JavaScript, so there is no redefined getter for a detection script to notice. The cost, noted in the module’s own comments, is that the flag historically triggered an “automation” infobar warning in the browser chrome, removable through enterprise policy on Linux but a visible artifact otherwise.
This is the patch most stealth users assume is bulletproof, and it mostly is for the direct read. But navigator.webdriver is no longer the only place the automation signal surfaces. A detection script can call navigator.userAgentData.getHighEntropyValues and inspect the brand list, where headless Chrome historically advertised a HeadlessChrome brand entry that has nothing to do with the webdriver flag and survives the Blink flag untouched. So the patch closes the boolean read and leaves a parallel channel open, which is the recurring shape of this whole exercise. For the wider catalog of where the automation signal leaks beyond this one flag, see headless Chrome detection.
chrome.runtime and the fabricated window.chrome surface
Real desktop Chrome exposes a window.chrome object. It carries extension and app plumbing: chrome.runtime, chrome.app, plus the timing oddities chrome.csi and chrome.loadTimes. Headless Chromium launched without extensions historically left this object missing or hollow, and the absence is trivial to detect. A script reads window.chrome and chrome.runtime, sees undefined, and flags the visitor. Four separate evasion modules exist to rebuild this surface, and chrome.runtime is the most elaborate of them.
The chrome.runtime module fabricates the runtime object from a captured snapshot of a real Chrome’s properties stored in a JSON file, then layers working method stubs on top. The stubs are not decorative. runtime.sendMessage validates its arguments the way real Chrome does, checking that an extension ID is the expected length and character range and throwing a matching TypeError when it is not. runtime.connect returns a port object whose listeners are wired up but whose channel is dead, so it looks like a real disconnected port rather than a stub. The module also respects a real-browser quirk: chrome.runtime is only populated on secure origins, so the patch skips insecure HTTP pages unless explicitly told otherwise with runOnInsecureOrigins. A detection script that probes chrome.runtime over plain HTTP and finds it present where real Chrome would not expose it has caught a careless spoof, and the module avoids that.
The chrome.app module is the simpler sibling. It fabricates chrome.app.isInstalled returning false, plus the InstallState and RunningState enumerations and methods like getDetails and getIsInstalled that return the right shape when called with no arguments and throw the right TypeError when called wrong. The chrome.csi and chrome.loadTimes modules restore two deprecated timing functions that real Chrome still carries for backward compatibility and that headless dropped. Individually each is small. Together they reconstruct enough of the window.chrome object that a script walking its properties does not immediately fall off a cliff of undefined.
There is a deeper problem these modules cannot fully solve, and it is worth being plain about. The exact property layout and method behaviour of window.chrome is not formally documented; the snapshot in the JSON file is a capture from some specific Chrome build at some specific time. Real Chrome’s runtime surface drifts version to version. A fabricated object frozen at one snapshot will, over time, diverge from whatever the current real Chrome exposes, and a detection vendor that keeps its own fresh reference can diff the two. That gap is the structural weakness of every “rebuild the object from a capture” approach, and it is the subject of why stealth plugins lose.
The permissions contradiction
This one is a favourite because it does not rely on any property being missing. It relies on two correct-looking values disagreeing with each other. Real Chrome, queried about its notification permission, gives you a consistent answer through two different APIs. The legacy Notification.permission property and the modern navigator.permissions.query({name: 'notifications'}) agree. Headless Chrome historically did not make them agree. On an insecure origin, headful Chrome reports Notification.permission as denied while permissions.query reports a state of prompt; headless inverted the relationship, producing a contradiction that exists nowhere in a normal browser. A detection script reads both and compares them. No single value is wrong. Their combination is impossible, and impossibility is a clean signal.
The navigator.permissions module patches Permissions.prototype.query. It wraps the method, watches for a query about notifications, and when it sees one on an insecure origin it returns a PermissionStatus-shaped object reporting state: 'denied' so the two APIs line up the way they do in real Chrome. The wrapped object has its prototype set to PermissionStatus.prototype so an instanceof check passes and the returned value looks like the genuine article rather than a plain object wearing a costume.
What makes this patch instructive is that it is the textbook case for the cross-signal detection model that modern anti-bot vendors run on. They rarely block on a single bad value any more, because single values are cheap to spoof. They block on combinations that should be impossible. The permissions contradiction was one of the earliest and clearest examples: two getters that must agree, and a headless build where they did not. Patch the disagreement and the signal goes quiet, but the detection philosophy it represents is the one that now governs everything else, including WebGL and the runtime surface. The detail of that philosophy is in server-side versus client-side bot detection.
navigator.plugins and the synthetic mimeType arrays
For most of Chrome’s life, navigator.plugins on a desktop browser was non-empty. It listed a small set of built-in entries tied to PDF viewing and Native Client, cross-referenced with a matching navigator.mimeTypes array. Headless Chromium reported an empty plugins list, length zero, and that emptiness was one of the original headless tells documented back when people still argued about whether headless was detectable at all. An empty navigator.plugins on something claiming to be desktop Chrome was a near-certain bot.
The navigator.plugins module rebuilds the arrays from a captured data.json of real plugin and mimeType definitions. The mechanics are fiddlier than they sound, because PluginArray and MimeTypeArray are not ordinary arrays. They support named property access (you can index a plugin by its filename), they are live-ish host objects, and each plugin and each mimeType point at each other. A Plugin lists the MimeType objects it handles, and each of those MimeType objects points back at the Plugin that handles it. The module reconstructs that bidirectional cross-reference, building generatePluginArray and generateMimeTypeArray helpers that produce objects with the right named-property behaviour and the right circular links, reusing proxy references for entries like application/x-pnacl so the circular structure does not spiral.
The reason the cross-reference matters is that a detection script does not have to trust the array’s length. It can pick a plugin, follow it to one of its mimeTypes, follow that mimeType’s enabledPlugin back, and check it lands on the same object it started from. A naively-faked array where the links do not close fails that walk. The module gets the topology right, which is most of the work. What it cannot make right is novelty: the plugin list is a capture, and as real Chrome’s built-in plugin set changes, a frozen capture drifts. This is the same staleness problem as the window.chrome snapshot, in a different costume.
webgl.vendor: spoofing the GPU name without the GPU
WebGL is where the spoof gets genuinely hard, because the value being faked is downstream of physical hardware. The WEBGL_debug_renderer_info extension exposes two strings: UNMASKED_VENDOR_WEBGL, the numeric constant 37445, and UNMASKED_RENDERER_WEBGL, the constant 37446. Real machines return things like a vendor of Google Inc. (Intel) and a renderer naming a specific Intel, AMD, or Apple GPU. Headless Chromium running without a real GPU falls back to software rendering and returns a vendor of Google Inc. with a renderer of Google SwiftShader. SwiftShader is Chrome’s software rasteriser, and its name in the renderer string is a direct announcement that no real GPU is present, which is a strong bot signal.
The webgl.vendor module hooks getParameter on both WebGLRenderingContext.prototype and WebGL2RenderingContext.prototype. When getParameter is called with 37445 it returns the spoofed vendor, and with 37446 it returns the spoofed renderer; everything else passes through to the original. The module’s defaults are a vendor of Intel Inc. and a renderer of Intel Iris OpenGL Engine, which is a plausible string for a Mac, and both are configurable so the operator can match whatever platform they are claiming to be.
Here is where the WebGL patch is weaker than the others, and it is structural rather than a bug. Swapping two strings does nothing to the actual rendered output. WebGL fingerprinting does not stop at the vendor name. A detection script can render a known scene to an offscreen canvas, hash the pixels, and compare the hash against what a real machine with that claimed GPU produces. SwiftShader’s software rasteriser produces different pixels from a real Intel or Apple GPU, antialiasing and floating-point rounding included, so the renderer string can say “Intel Iris” while the pixels say “software rasteriser”. The strings and the pixels disagree, and the disagreement is exactly the impossible-combination signal the permissions patch was built to avoid. Worse, a careless operator who sets an Apple GPU renderer string while running on a Linux server, or claims a discrete GPU while the rendered output looks like integrated graphics, manufactures a mismatch that a consistent unspoofed browser would never have produced. The vendor string is the easy half. The pixels are the half that gives it away, and the module touches only the easy half.
iframe.contentWindow: the srcdoc edge case
The last evasion in scope is the most specific, and it is a good illustration of how narrow some of these patches are. It concerns iframes created with the srcdoc attribute, where the frame’s content is supplied inline as an HTML string rather than loaded from a URL. A Chromium bug, tracked against Puppeteer as issue 1106, caused srcdoc-powered iframes in automated contexts to expose the automation through their contentWindow. Properties that a normal nested browsing context resolves cleanly, like contentWindow.self and contentWindow.frameElement, did not behave correctly, and a detection script that created such an iframe and inspected it could tell.
The iframe.contentWindow module wraps the contentWindow getter with a proxy so the returned window behaves like a real nested window. It redirects self to the proxied window itself so a contentWindow.self === window.top check returns false the way it should for a genuine child frame, points frameElement back at the iframe element, and hides the proxy’s own enumerable artifacts (like a stray numeric 0 property) that would otherwise reveal that the window was wrapped. The module’s own notes are explicit that only srcdoc iframes hit the bug; ordinary URL-loaded iframes were fine and are left alone.
This patch is the clearest case in the whole bundle of fixing a specific Chromium bug rather than a fundamental design leak, which means its relevance is tied to that bug’s lifetime. Once Chromium fixed the underlying behaviour the patch became a fix for a problem that no longer exists, and applying it can do more harm than good. A proxy wrapped around contentWindow is itself detectable if the wrapping is imperfect, so a patch that wraps a window which no longer needs wrapping has swapped a closed leak for an open one. That is a recurring hazard with a bundle of historical patches: some of them now defend against bugs the platform already retired, and the patch is the only remaining anomaly on the page.
How the patches hide themselves, and where they fail
Return to the second job, the one that matters more than the spoof: hiding the patch. Most of these modules do not assign a plain JavaScript function where a native one belongs. They install a proxy, and they run that proxy through the _utils helpers. The reason is Function.prototype.toString. Call .toString() on a genuine native method and you get function getParameter() { [native code] }. Call it on a naive replacement and you get the actual JavaScript source, which is a dead giveaway that the method was swapped. patchToString exists to defeat exactly that probe: it proxies toString itself so that a patched function reports the native-code string, and it handles the recursive case where a script calls toString on toString.
That handles the obvious probe. The leaks that remain are subtler, and the Castle research team has documented them directly. A Proxy changes how some operations throw. Trigger an error inside a trapped operation and the exception’s stack trace, or the exact exception type, can differ from what an unwrapped native method produces. stripProxyFromErrors is the countermeasure: it catches errors in the traps and rewrites their stacks, using an anchor line to find and delete the frames that name the proxy or the helper file, falling back to a blacklist filter if the anchor is missing. It is careful work. It is also a patch on a patch, and it can only scrub the stack frames it knows to look for. A detection script that provokes an error through an unexpected path, or that times the cost of the proxy indirection, or that compares the descriptor of a wrapped property against the descriptor real Chrome would have, is probing a surface the helper never anticipated. The honest summary, and Castle states it plainly, is that there is no official API to inspect a proxy, but a proxy can still be detected indirectly through these side effects.
This is the ceiling that the whole bundle runs into. Each patch closes a value-level leak and opens a behaviour-level one, smaller and harder to find but still there. A property that should be native is now a proxy. An error that should have one stack now has another, scrubbed to look right but assembled by a script. The patches win against detection that reads values and lose against detection that reads the texture of the runtime, and over the last few years detection has moved decisively toward reading texture. The most current example is not in this plugin at all: it is the Chrome DevTools Protocol itself, where simply running Puppeteer over CDP leaves side effects that no in-page patch can reach, covered in detecting CDP in the wild.
What the new headless mode did to all of this
There is a twist that reframes the entire bundle. A large share of these patches were built against the old headless Chrome, the separate stripped-down binary that behaved differently from desktop Chrome in dozens of small ways. In late 2022 Google shipped a new headless mode, invoked with --headless=new, that runs the same Chrome binary as headful with the window simply not drawn. The new mode closed many of the gaps these patches were built to fill. The window.chrome object is present. navigator.plugins is populated. The permissions APIs agree. The WebGL path can reach a real GPU where one is available. Security researchers who measured the new mode in early 2023 described its fingerprint as close to a normal desktop Chrome, which is precisely what the stealth plugin had spent years trying to fake.
That has two consequences worth sitting with. First, several evasion modules now patch values that are already correct, and a patch that overwrites a correct native value with a proxy returning the same value has strictly increased the detection surface for no benefit. The cleanest configuration on new headless Chrome is fewer patches, not more, because the residual proxy and stack-trace tells are the only anomalies a well-configured new-headless browser would otherwise present. Second, the signals that survived the new mode are exactly the ones no in-page patch can touch: navigator.webdriver and the HeadlessChrome brand still flag automation when present, and the CDP connection underneath Puppeteer is observable from the page through side effects the plugin cannot reach. The detection war did not end. It moved out of the layer this plugin operates in.
Read the evasions folder today and it is a record of a specific period: roughly 2017 to 2022, when headless Chrome was a different binary with a catalog of small differences, and patching them one property at a time was a viable strategy. The plugin still works, still closes the value-level leaks it always did, and is still a fine reference for understanding what those leaks were. But the binary it was built to disguise mostly stopped existing in 2022, and the detection it was built to defeat mostly stopped looking at values. A bundle of nineteen folders, each fixing a property, sits on one side of a line that the other side has long since walked past. The patches that still matter are the few that touch the launch arguments rather than the page, because those are the only ones that change what Chrome is rather than what Chrome says about itself.
Sources & further reading
- berstend and contributors, puppeteer-extra-plugin-stealth/evasions — the evasion modules themselves; each subfolder is one patch with its own source and notes.
- berstend and contributors, navigator.webdriver evasion source — the prototype-delete versus AutomationControlled-flag split by Chrome version.
- berstend and contributors, chrome.runtime evasion source — the fabricated runtime object, method stubs, and secure-origin guard.
- berstend and contributors, navigator.permissions evasion source — the Notification.permission versus permissions.query reconciliation.
- berstend and contributors, webgl.vendor evasion source — the getParameter hook on constants 37445 and 37446 and its Intel defaults.
- W3C (2018), WebDriver specification — defines the navigator.webdriver flag that automation must expose.
- Antoine Vastel, Castle (2025), From Puppeteer stealth to nodriver: how anti-detect frameworks evolved — the proxy/toString patching model and the error-stack leaks it cannot fully hide.
- Sebastian Wallin, Castle (2025), The role of WebGL renderer in browser fingerprinting — SwiftShader as a software-rendering tell and cross-signal GPU/OS mismatches.
- DataDome (2024), How new headless Chrome and the CDP signal are impacting bot detection — what —headless=new closed and the CDP signal that replaced it.
- deviceandbrowserinfo (2024), Detecting headless Chrome instrumented with Puppeteer — userAgentData brands, webdriver, and CDP serialization side effects in current detection.
- Antoine Vastel (2023), New headless Chrome has a near-perfect browser fingerprint — measurement of the new headless mode’s fingerprint against desktop Chrome.
- Intoli (2018), It is not possible to detect and block Chrome headless — the original catalog of value-level headless tells these patches were built against.
Frequently asked questions
how does puppeteer-extra-plugin-stealth inject its patches before page scripts run
Every evasion uses page.evaluateOnNewDocument, the Puppeteer wrapper for the CDP method that registers a script to run on each fresh document before any page script executes. The patch redefines getters or wraps methods first, so when a detection tag later reads a property it sees the spoofed value. The catch is that the patch runs in the page's main world, the same JavaScript context the site's code uses, so the spoof itself is visible to that code and must hide its own presence.
why does the navigator.webdriver patch use a launch flag instead of deleting the property
The module carries two approaches and picks by Chrome version. Before Chrome 88, the webdriver property sat on the Navigator prototype and could be deleted so a lookup resolved to undefined. From Chrome 88 onward it instead launches with --disable-blink-features=AutomationControlled, which stops Chrome from setting the property at the source. The launch flag is better because it leaves no redefined getter in JavaScript for a detection script to notice, though it historically triggered an automation infobar warning.
what makes the navigator.permissions contradiction detectable without any missing value
It relies on two correct-looking values disagreeing. Real Chrome returns a consistent notification-permission answer through both Notification.permission and navigator.permissions.query, but headless Chrome historically did not make them agree. On an insecure origin, headful Chrome reports denied for one and prompt for the other in a consistent way, while headless inverted the relationship, producing a combination that exists in no normal browser. No single value is wrong; the impossible combination is the signal.
why can't spoofing the WebGL vendor string defeat GPU fingerprinting
The webgl.vendor module hooks getParameter to return spoofed strings for constants 37445 and 37446, but swapping two strings does nothing to the rendered output. A detection script can render a known scene to an offscreen canvas, hash the pixels, and compare against what the claimed GPU produces. SwiftShader's software rasteriser produces different pixels than a real GPU, so the renderer string can claim Intel Iris while the pixels reveal software rendering, creating the same impossible mismatch the patch was meant to avoid.
does the new headless Chrome mode make the stealth patches unnecessary
The --headless=new mode shipped in late 2022 runs the same Chrome binary as headful with the window not drawn, and it closed many gaps the patches filled: window.chrome is present, navigator.plugins is populated, and the permissions APIs agree. As a result some modules now overwrite already-correct values with proxies, which only increases the detection surface. The signals that survive, like navigator.webdriver, the HeadlessChrome brand, and CDP side effects, are exactly the ones no in-page patch can reach.
Further reading
How anti-bot systems fingerprint the JavaScript runtime
A reference on the JS-runtime fingerprinting surface: error stack formats, Function.prototype.toString, feature and timing probes, property enumeration order, and the engine quirks that betray a patched or automated browser.
·21 min readHeadless Chrome detection: every tell from navigator.webdriver to missing codecs
A reference catalog of the signals that give headless Chrome away: the webdriver flag, empty plugin lists, the permissions contradiction, missing proprietary codecs, software WebGL renderers, and what --headless=new actually fixed.
·21 min readThe HeadlessChrome user-agent token and the long tail of headless signals
Traces the HeadlessChrome user-agent token from its 2017 origin through the 2023 --headless=new rewrite, and the second-order tells that survive a clean UA: permissions inconsistencies, software WebGL, missing codecs, and CDP side effects.
·20 min read