Why stealth plugins lose: the gap between patching properties and matching real Chrome
A stealth plugin makes a promise that sounds reasonable. Automated Chrome leaks a handful of tells, the promise goes, so override the properties that leak, restore the values a normal browser would report, and the automation disappears into the crowd. Set navigator.webdriver back to undefined. Put the plugins array back. Fix the user-agent so it stops saying HeadlessChrome. Patch enough of these and the fingerprint converges on a real browser’s. The logic is clean, the library is one npm install away, and for a few years it mostly worked.
It does not work now, and the reason is structural rather than a matter of one more patch. A detector is not checking whether navigator.webdriver is true. It is checking three other things: whether the property has been tampered with, whether the value you reported is consistent with every other value you reported, and whether some signal the plugin never touches still gives you away. Each of those three checks is a different kind of trap, and a property-patching plugin walks into all three by design. This post is about why. It is the conceptual companion to the patch-by-patch walkthrough of puppeteer-extra-plugin-stealth; that post is the what, this one is the why it cannot win.
The sections below build the argument in order. First, the model: what a detector actually measures, which is not the same thing the plugin patches. Then the toString leak, the oldest and cleanest example of a detector testing for the patch itself. Then consistency, the trap that turns every spoofed value into a liability rather than a fix. Then the signals the plugin structurally cannot reach, from the native C++ rendering stack down to the CDP transport. Then the treadmill: why the open-source nature of these plugins guarantees the half-life of any given patch is short, and why the frontier has moved away from patching properties entirely. The through-line is simple. Stealth-by-patching loses because it is playing a different game than the one the detector is playing.
What a detector actually measures
Start with the thing people get wrong. The naive mental model of bot detection is a checklist of bad values. Is webdriver true? Does the UA contain HeadlessChrome? Is the plugins array empty? Tick the boxes, and if any box is ticked, block. If that were the whole game, a stealth plugin would win permanently, because every box is a property and every property can be overridden.
Real detection works on a different object. It does not score values in isolation; it scores the joint distribution of everything the browser reports, plus the manner in which it reported each thing, against a model of what real browsers look like. A real Chrome on real hardware emits thousands of observable signals that are not independent. The GPU renderer string constrains the canvas hash. The platform constrains the set of fonts. The user-agent constrains the client-hint headers. The timezone constrains the plausible IP geolocation. These correlations are stable across the genuine population because they come from the same physical machine and the same browser build. A fingerprint is not a list. It is a point in a very high-dimensional space, and the legitimate population occupies a thin manifold inside that space.
A property patch moves one coordinate. It does not, and cannot, move the whole point back onto the manifold, because the plugin author does not know all the constraints, and even the ones they know they cannot all satisfy at once with a JavaScript override that runs after the page loads. So the detector has three angles of attack that a value-checklist would never have. It can ask whether a coordinate was moved by hand, which leaves marks. It can ask whether the coordinates are mutually consistent, which a partial patch breaks. And it can read coordinates the plugin never knew were coordinates. The rest of this post is those three angles, in that order.
*A checklist treats each signal as an independent box to fix. A detector treats the whole report as one point and asks whether it lands where real browsers land. Patching one coordinate lifts the point off the manifold rather than onto it.*The patch leaves a mark: the toString trap
The cleanest example of a detector testing for the patch itself is older than the modern stealth plugins, and it has never been fully solved. It uses one feature of JavaScript: a function can be turned back into a string.
Every JavaScript function carries a toString that returns its source. For a function written in JavaScript, that is the actual source text. For a function implemented inside the browser’s C++ engine, the engine returns a fixed placeholder of the form function name() { [native code] }. This is specified behavior, not an accident. The presence of [native code] is the browser’s own attestation that a function is built in rather than user-defined.
This is a problem for any stealth technique that replaces a native function or property accessor with a JavaScript one. The naive override is immediately visible: call toString() on the replacement and the engine hands back the JavaScript source, not [native code]. So stealth plugins patch the patch. They override toString itself to lie about the replaced function, returning the native placeholder when asked. And then they have to override toString on toString, because the override of toString is itself a JavaScript function that would otherwise reveal itself. The recursion is the tell. You are now maintaining a self-consistent fiction about which functions are native, and the fiction has to hold under every angle of inspection a detector can think of.
It does not hold, for a reason that has been public since at least 2019. A detector does not have to trust the current page’s Function.prototype.toString. It can fetch a clean, unpatched reference to the native toString from another browsing context, by creating an iframe and reaching into its contentWindow. The iframe gets a fresh JavaScript environment with pristine built-ins. Borrow that environment’s Function.prototype.toString, call it on the suspect function from the parent page, and the parent page’s overrides are bypassed entirely, because the parent never got a chance to patch the child’s globals before the check ran. The patched property now stringifies to its real JavaScript source in front of a detector that brought its own ruler.
The toString trap generalizes past toString. The same shape recurs every time a plugin replaces a native object with a Proxy or a plain object to intercept access. A Proxy is convincing from the front but it leaks from the side. Subtle differences in error stack traces and in the exact exception type thrown by an unusual operation can reveal that an object is a Proxy rather than the genuine built-in. The plugin patches the obvious face of the object; the detector pokes the parts of the object’s behavior the plugin never thought to forge. There is no patch for “be a real native object.” There is only an ever-longer list of behaviors to mimic, and the detector picks the one you missed.
The lesson of this whole class is the first structural reason the approach loses. Patching introduces a new artifact that did not exist before the patch. A real browser has no override on Function.prototype.toString. A real browser has no Proxy standing in for navigator. The act of hiding the first tell creates a second tell, one rung up. You can keep climbing the ladder, but the detector only has to find one rung you have not reached yet, and it gets to choose which rung to inspect.
The getter that should not exist
There is an even sharper version of “the patch leaves a mark,” and it is specific to how stealth plugins restore navigator.webdriver.
In a normal, non-automated Chrome, navigator.webdriver is false. Under automation it is true. The obvious fix is to redefine the property so it returns false or undefined. But the way you redefine it matters more than the value you land on. A real Chrome implements webdriver as a getter on Navigator.prototype, and that getter is native code. A naive patch deletes the property and installs a new accessor on the instance, or defines a data property on navigator directly. Now the property descriptor is wrong in a way that has nothing to do with its value. The getter, if you inspect it, stringifies to a JavaScript arrow function instead of [native code]. The property sits on the wrong object in the prototype chain. Its enumerability or configurability differs from the genuine article.
So a detector that cares about webdriver does not read navigator.webdriver at all. It reads Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver'), checks whether the getter exists where it should, and stringifies that getter to see if it is native. A patched browser that reports webdriver === false but whose getter stringifies to () => false has told the detector two things: that the value is the safe one, and that someone went out of their way to make it the safe one. The second fact is worth more than the first. Real users do not carry hand-installed getters that return the exact value an automation framework would want to hide. The presence of the patch is a higher-entropy signal than the value the patch was trying to set.
This is the trap in its purest form. The detector is not looking for webdriver. It is looking for evidence that you tried to hide webdriver. Any technique that operates by overriding the property at the JavaScript layer produces that evidence somewhere in the descriptor, the prototype position, or the stringification. The only way to not leave the mark is to not make the edit at the JavaScript layer at all, which is exactly where the frontier went and which the last section returns to.
Consistency: every spoof is a new liability
The second angle is the one that turns a stealth plugin’s own successes against it. Suppose the plugin perfectly hides the act of patching, so that no toString or descriptor check can see the override. It has still moved the value. And the moment a value moves without the rest of the fingerprint moving with it, the detector has a consistency check waiting.
The principle is old and the reference implementation is public. The “has lied” family of checks goes back to FingerprintJS’s 2015 work, code originally written for the AmIUnique research project at Inria, and the same logic now runs in production at large sites. The idea is to take signals that, in a genuine browser, must agree, and flag the cases where they disagree. If the user-agent claims Windows, then navigator.platform, the CPU description, the set of fonts, and the touch capabilities should all also look like Windows. If navigator.language is en-US then the first entry of navigator.languages should be en-US too. These are not independent dials. In a real browser they are downstream of the same configuration, so they move together or not at all.
A stealth setup, or any operator hand-rolling overrides on top of one, breaks these correlations constantly because it patches the famous signals and forgets the obscure ones. The famous signal is the user-agent. So the operator spoofs a Windows Chrome UA on a Linux automation host, and forgets that navigator.platform still says Linux x86_64, that the WebGL renderer string still names a Linux Mesa driver or a software rasterizer, and that the client-hint header Sec-CH-UA-Platform still says the wrong thing. Each of those is a contradiction with the spoofed UA, and a contradiction is far more damning than any single suspicious value, because real browsers essentially never contradict themselves on these axes.
The modern version of this extends well past navigator.platform. Chromium browsers report their identity in several parallel places that all have to agree: the User-Agent string, the navigator.userAgentData object, the Sec-CH-UA family of request headers, and navigator.platform. A consistency check looks for combinations that should not be able to coexist, a UA claiming one platform while client hints claim another. The GPU adds a whole second axis. The unmasked WebGL renderer string names a specific graphics stack, and that stack has to be physically plausible for the device the rest of the fingerprint describes. A fingerprint claiming a mobile Android device while exposing a desktop-class Mac GPU is, to quote the detection writeup directly, “a strong signal that the fingerprint has been forged.”
This is why consistency is structurally worse for the plugin than the toString trap. The toString trap is a fixed set of well-known patches you could in principle enumerate and hide. Consistency is open-ended. The number of pairwise correlations across a full fingerprint is enormous, the operator only patched the ones they had heard of, and the detector gets to pick any pair to cross-check. Worse, fixing a consistency failure usually requires moving more values, and each new moved value opens new pairs that have to be kept consistent in turn. You do not converge. A genuinely consistent forged fingerprint is achievable, but only by controlling the values at a layer below JavaScript so they come out coherent by construction. A plugin that paints over them one at a time, after the fact, is solving a constraint-satisfaction problem with a paintbrush.
The signals the plugin cannot reach
The third angle is the simplest to state and the hardest for a JavaScript plugin to do anything about. There are whole categories of signal that a script running in the page cannot touch, because they are produced below the JavaScript boundary or outside the page entirely. The plugin patches what navigator exposes. It has no authority over the layers underneath.
The most concrete category is the native rendering and media stack. Canvas hashing, WebGL parameters, the audio fingerprint, and font metrics are computed by Chromium’s C++ graphics and media code, not by anything a content script can intercept cleanly. When a headless or automation-oriented Chrome runs without a real GPU, it falls back to a software rasterizer, historically SwiftShader, and the WebGL UNMASKED_RENDERER_WEBGL string names it. Software rendering has its own distinctive numeric behavior, precision quirks and parameter values that mark it out as not a physical GPU. A JavaScript plugin can override the renderer string to claim an NVIDIA or Apple GPU, but it cannot change what the rasterizer actually computes, so the canvas and WebGL pixel output stay consistent with software rendering while the string claims hardware. That is another consistency failure, manufactured by the very attempt to fix the renderer signal.
The second category is the transport. Long before any JavaScript runs, the connection has already emitted a TLS ClientHello and, on HTTP/2, a particular set of frame settings and header-ordering choices. These fingerprints come from the network library, not the page, and a content-layer stealth plugin has zero influence over them. A request that presents a Chrome user-agent over a TLS stack that fingerprints as Python’s or Node’s networking is a contradiction the plugin never even sees, because it lives in a part of the request the plugin does not run in. The detail of how these network fingerprints are built is its own subject; the point here is only that they exist entirely outside the plugin’s reach, and they are checked against the browser the UA claims to be. The same applies to proxy and ASN reputation: the network path is scored independently of anything the page reports.
The third and most interesting category is the automation transport itself: the Chrome DevTools Protocol. Puppeteer, Playwright, and Selenium all drive Chrome by speaking CDP over a WebSocket, and CDP usage has side effects that are observable from inside the page without any cooperation from the page’s JavaScript. The most famous of these is the Runtime.enable signal. Most automation libraries call CDP’s Runtime.enable early, to discover execution contexts so they can evaluate code in frames. Enabling the Runtime domain changes the browser’s behavior in a way a script can detect.
The mechanism is worth getting right, because the public account of it is precise. When the Runtime domain is enabled, Chrome serializes objects passed to console methods so it can ship a preview across the CDP WebSocket via the Runtime.consoleAPICalled event. The classic detection exploited that serialization. A script constructed an Error whose stack property was a getter, logged it, and waited. If the Runtime domain was live, the serialization step read the stack property to build its preview, which fired the getter; the getter flipped a flag, and the script now knew CDP was attached. No JavaScript override anywhere in the page could hide this, because the leak came from the browser’s own serialization, not from any property the page controlled. The same writeup notes the technique’s collateral damage: it also flags genuine human users who happen to have DevTools open, since DevTools enables the same domain.
Two things about the Runtime.enable signal make it the perfect closing illustration of why patching loses, and both come from its actual history rather than my speculation. First, no stealth plugin could fix it, because the leak is in the protocol layer the plugin does not occupy; the fix had to come from automation frameworks that avoid enabling the Runtime domain in the page’s main context, or from tools like nodriver that avoid CDP usage altogether. Second, and this is the part worth sitting with, the signal stopped working in 2025 for a reason that had nothing to do with anyone’s stealth. Two V8 commits in May 2025, described as avoiding error side effects in DevTools and applying a getter guard throughout error preview, made the serialization skip user-defined getters during error preview. After that, the classic check fires for no one. A detection technique that the entire anti-detect ecosystem had spent years working around quietly evaporated when an unrelated V8 hardening change shipped, and, as the researcher who documented it observed, almost nobody noticed.
The treadmill
Step back from any single signal and the structural problem is a property of the whole arrangement, not of any one patch.
A stealth plugin is open source. That is its distribution model and its undoing. Every evasion it ships is readable by the people building detectors, who can derive an exact discriminator for each technique the plugin uses, because the technique is right there in the repository. The plugin overrides Function.prototype.toString in a specific way; the detector tests for that specific way. The plugin installs the webdriver getter with a particular descriptor shape; the detector reads that descriptor. There is no information asymmetry. The defender publishes the playbook, and the playbook becomes the signature. The most-read stealth plugin in this space had not seen a substantive update for a long stretch while detection moved on, which is exactly the failure mode you would predict: a static, public set of patches against an adversary that reads the patches.
Even a maintained plugin is on a treadmill. Each patch fixes a tell and creates a subtler one, the detector finds the subtler one, the plugin patches that, and so on. The cycle has no terminal state where the fingerprint is finished, because “looks exactly like real Chrome” is not a finite checklist; it is consistency across an open-ended set of correlated signals plus the absence of any tampering artifact across an open-ended set of inspection methods. You can win any individual round and still be losing the match, because the detector picks the next signal and you are always responding.
The frontier responded to this by changing layers. The interesting work moved from patching properties inside the page to controlling the browser from outside it, so the values come out right by construction and there is no override to detect. The clearest statement of the shift is the move from puppeteer-stealth-style JavaScript patching toward tools that drive the browser through operating-system-level input and avoid CDP’s observable side effects, mimicking a regular browser session rather than instrumenting one and then hiding the instrumentation. The source puts it plainly: the problem stopped being about masking automation through evasive patches and became about rethinking the architecture so there is nothing to mask. That is the same idea behind nodriver and undetected-chromedriver, and behind the anti-detect browsers that patch values at the engine level before the page ever sees them. None of those approaches is permanently safe either; the CDP transport, the network fingerprint, and the consistency checks still apply. But they are at least playing the detector’s game rather than a different one, because they move the whole point rather than one coordinate.
What the gap actually is
The gap in the title is the distance between two operations that look identical from inside a stealth plugin and could not be more different from outside it. Patching a property sets a value. Matching real Chrome means landing on a high-dimensional manifold of mutually consistent signals, every one of them produced by the same physical machine and the same browser build, with no override anywhere that a fresh iframe or a descriptor read could expose. The first is a one-line edit. The second is a constraint the plugin has no mechanism to satisfy, because it operates in the wrong layer, after the fact, with incomplete knowledge of the constraints, against an adversary who can read its source.
That is why the three traps are not three bugs to be fixed in the next release. They are the three faces of one fact. A detector that tests for the patch will always find the artifact a patch creates. A detector that tests for consistency will always find the correlations a partial patch breaks. A detector that reads below the JavaScript layer will always find the signals the plugin cannot reach. A property-patching plugin walks into all three not because it is poorly written but because property-patching is the wrong primitive for the problem. The Runtime.enable story is the whole thing in miniature: a tell no plugin could fix, worked around for years at the protocol layer, and finally killed not by anyone’s evasion but by an unrelated change to how V8 previews errors. The signals move on their own schedule, set by browser engineers and detector authors, and the plugin is the one party in the loop with no purchase on either.
Sources & further reading
- Antoine Vastel, Castle (2025), From Puppeteer stealth to Nodriver: How anti-detect frameworks evolved to evade bot detection — why JS-injection patching leaks and why the frontier moved to protocol-level evasion.
- Antoine Vastel, Castle (2025), Why a classic CDP bot detection signal suddenly stopped working (and nobody noticed) — the Runtime.enable getter leak and the May 2025 V8 commits that broke it.
- Antoine Vastel, Castle (2026), Detecting forged browser fingerprints for bot detection, lessons from LinkedIn — the “has lied” consistency checks across UA, platform, languages, and GPU.
- Rebrowser (2024), How to fix Runtime.enable CDP detection of Puppeteer, Playwright and other automation libraries — the CDP commands involved and how the Runtime domain leak surfaces in the page.
- sveba (2026), How V8 Leaks Your Headless Browser’s Identity — the V8 inspector internals behind the error-stack and prototype-proxy CDP leaks.
- adtechmadness (2019), JavaScript tampering – detection and stealth — the Function.prototype.toString reflection trap and the cross-context iframe native reference.
- DataDome (2024), Detecting Headless Chrome’s Puppeteer Extra Stealth Plugin with JavaScript Browser Fingerprinting — vendor account of fingerprinting the stealth plugin’s patches.
- Chrome for Developers (2024), Removing —headless=old from Chrome — the new headless timeline: —headless=new in Chrome 112, old headless removed in Chrome 132.
- Antoine Vastel (2023), New headless Chrome has been released and has a near-perfect browser fingerprint — why the new headless mode closed most of the old obvious tells.
- Mozilla (2011), Bug 650299: Don’t leak the proxy call trap with Function.prototype.toString — early evidence that Proxy and toString interactions are a known leakage surface in engines themselves.
- berstend, puppeteer-extra (readme), puppeteer-extra-plugin-stealth — the plugin’s own list of evasion modules, the public playbook detectors read.
Further reading
How puppeteer-extra-plugin-stealth works, patch by patch
A walkthrough of the individual evasions in puppeteer-extra-plugin-stealth: the webdriver flag, chrome.runtime, the permissions contradiction, plugins and mimeTypes, the WebGL vendor, and iframe.contentWindow, with what each patch fixes and where it leaks.
·21 min readnodriver and undetected-chromedriver: what they patch and why it eventually breaks
Traces the undetected-chromedriver lineage into its successor nodriver: the cdc_ binary patch, the navigator.webdriver flags, dropping the chromedriver binary and Selenium for raw CDP, and why each layer still gets caught.
·20 min readThe lifecycle of a stealth patch: discovery, fix, detection, and re-discovery
Traces how a single browser-automation stealth patch moves through its life: a signal is found, the patch hides it, the patch itself becomes a fingerprint, and a new signal replaces the old one. With real examples and the economics of the treadmill.
·19 min read