๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Guides

Stealth Plugins for Puppeteer and Playwright: What Works

Stealth plugins patch the known automation tells in Puppeteer and Playwright. Which ones still matter, why the patch set is a signature, and how to test yours.

S SparkProxy 2 19 min read
Share
Stealth Plugins for Puppeteer and Playwright: What Works

Stealth plugins patch a fixed list of known automation tells in a headless browser, which is enough for soft targets, but the patch set is public and consistent enough to be a fingerprint of its own, so an over-patched browser is often easier to classify than an honest one.

That sentence is the whole argument, and the rest of this guide is the evidence. You will get the actual evasion list, the tells that still carry weight in 2026, why the Chrome DevTools Protocol gives you away at a layer no page script can reach, why the most popular package has not shipped a release since March 2023, what the current forks do instead, and a test harness you can run against your own setup rather than copying somebody's config block.

One scope note up front. This is written for people collecting data they are permitted to collect: public pages, your own properties, competitor pricing you can legally observe. Using these techniques to get around an access control or a paywall is very likely a breach of the site's terms of service, and that is a decision with legal weight, not a technical detail.

What a Stealth Plugin Actually Does

puppeteer-extra is a plugin bus that wraps Puppeteer's launch. puppeteer-extra-plugin-stealth is a bundle of small evasion modules that ride on that bus. Each module does one of two things: it appends or removes a Chromium command-line switch at launch, or it injects a script that runs before any page script via Puppeteer's evaluateOnNewDocument, which maps to the CDP method Page.addScriptToEvaluateOnNewDocument.

That is the entire mechanism. It rewrites JavaScript properties in the page and edits launch flags.

Notice what is not on that list. Stealth plugins do not touch your TLS ClientHello, your HTTP/2 SETTINGS frame order, your IP address, your request cadence, or your mouse movement. They operate in the one layer that is cheapest for a detector to sandbox and cross-check, and they leave the layers below completely untouched. If a target is scoring you on TLS fingerprinting, no amount of JS patching moves the needle.

The evasion modules, in full

The evasions/ directory of the stealth plugin contains 17 modules:

ModuleWhat it changes
`chrome.app`, `chrome.csi`, `chrome.loadTimes`, `chrome.runtime`Rebuilds the `window.chrome` object that Chromium builds lack
`defaultArgs`Strips `--enable-automation` and related switches
`iframe.contentWindow`Restores `contentWindow` behavior for `srcdoc` iframes
`media.codecs`Adds the proprietary codec support Chromium omits
`navigator.hardwareConcurrency`Forces a plausible CPU count
`navigator.languages`Sets the accepted language list
`navigator.permissions`Fixes the Notification permission mismatch
`navigator.plugins`Fabricates the PDF plugin and MIME type arrays
`navigator.vendor`Sets the vendor string to Google Inc.
`navigator.webdriver`Removes the flag
`sourceurl`Strips the injected script's source URL from stack traces
`user-agent-override`Rewrites User-Agent, platform, and Accept-Language
`webgl.vendor`Spoofs the two UNMASKED WebGL strings
`window.outerdimensions`Makes `outerWidth` and `outerHeight` non-zero

Read that table as a snapshot of what headless Chromium looked like in 2022. Several entries fixed problems that Chrome itself has since fixed, and a few now create the mismatch they were written to remove.


The 17 Evasions and Which Tells Still Matter

Detection is not a checklist of booleans. It is a consistency score across a few hundred signals, and most of the individual tells the stealth plugin patches are worth almost nothing on their own in 2026. Here is an honest weighting:

TellHow it is readStealth patches itWeight today
`navigator.webdriver`One property read, defined in the [W3C WebDriver spec](https://www.w3.org/TR/webdriver2/#dfn-webdriver-active-flag)YesNear zero. Every detector assumes you removed it. Leaving it is a fail, removing it earns nothing
`HeadlessChrome` in the User-AgentString matchYesLow, but a free fail if you forget. Since Chrome 132.0.6793.0 the old headless mode ships only as the separate `chrome-headless-shell` binary, per the [Chromium headless docs](https://developer.chrome.com/docs/chromium/headless)
Missing `window.chrome`Property existenceYesLow. Signals a Chromium build rather than Chrome, which is a hint, not a verdict
Empty `navigator.plugins`Array lengthYesLow, and the mock is now more suspicious than the empty array
WebGL vendor and renderer`getParameter(UNMASKED_VENDOR_WEBGL)`Two strings onlyMedium, and usually negative. See the next section
Permissions and Notification mismatchTwo API reads comparedYesLow
CDP presenceProtocol side effects, no JS surface involvedNoHigh
Canvas, audio, and font renderingHashed output compared against a known-device corpusNoHigh
TLS and HTTP/2 fingerprintBelow the browserNoHigh
IP reputation and ASNEvaluated before a single byte of JS runsNoHigh
Behavior: timing, scroll, focus, dwellEvent streamsNoHigh on hardened targets

The pattern is hard to miss. Everything a stealth plugin can reach sits in the low-weight rows. Everything in the high-weight rows is out of its reach by design. For the broader signal inventory, see our breakdown of browser fingerprinting and the specific mechanics of headless browser detection.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Why the Patch Set Is Itself a Fingerprint

This is the part most tutorials skip. A detector does not have to test for the original tell. It can test for the patch.

Every evasion is an observable deviation from a stock browser, the source is public on GitHub, and the set of patched properties has been frozen for three years. That combination gives a vendor a stable class label. Detecting "this browser has been modified by puppeteer-extra-plugin-stealth 2.11.x" is a strictly easier problem than detecting "this browser is headless," because the patched artifact is more distinctive than the thing it replaced.

Four concrete leaks

Proxy frames in error stacks. The plugin wraps native functions in Proxy objects and spoofs Function.prototype.toString so they still print [native code]. The spoof holds for toString. It does not hold for exceptions. Throw inside a wrapped call and the trap frames can survive into Error.prototype.stack, giving a detector a string match on something no real browser produces.

The plugins mock. navigator.plugins in real Chrome is a live PluginArray whose entries share identity with navigator.mimeTypes entries. The mock rebuilds a plausible shape, but iteration order, Symbol.toStringTag, item() and namedItem() behavior, and property descriptor flags all have to match exactly. Getting a five-entry array shaped exactly right is much harder than getting an empty one.

WebGL contradiction. The webgl.vendor evasion patches exactly two getParameter constants. Everything else in the WebGL surface still tells the truth: the supported extension list, MAX_TEXTURE_SIZE, shader precision formats, and the actual pixel output of a render. Claim an Intel GPU on a container running SwiftShader and you have not hidden a software renderer, you have announced that something is rewriting your WebGL strings.

Hardware that disagrees with itself. navigator.hardwareConcurrency gets forced to a friendly value while navigator.deviceMemory, the WebGL renderer, screen dimensions, and actual benchmark timing still reflect a 2-vCPU cloud box.

The over-patching trap

Think about what each state costs you in a scoring model. An unpatched headless Chrome trips two or three weak signals and gets a moderate score. A stealth-patched browser removes those weak signals and adds several contradictions, and contradictions score much harder than absences, because a real user's browser is never internally inconsistent. There is no legitimate device on earth where the WebGL vendor string says Intel and the extension list says SwiftShader.

The practical rule: spoof nothing you cannot make consistent all the way down. A truthful Linux Chrome profile with a real GPU, running on a residential IP, beats a fake MacBook profile stitched together from patches. This is also the reason the mature tools have moved away from injection, which brings us to the maintained forks.


CDP Is Detectable Without Touching the JS Surface

Puppeteer and Playwright drive Chromium over the Chrome DevTools Protocol. To evaluate JavaScript in a page, both libraries have historically called Runtime.enable, which switches on the Runtime domain and starts emitting executionContextCreated events.

That call has observable side effects inside the page. A few lines of JavaScript can detect that it happened, and this is not theoretical: the rebrowser-patches project documents it as the primary leak and reports it in active use by the large anti-bot vendors. No stealth plugin addresses it, because a stealth plugin runs inside the page and the leak lives in the protocol layer above.

There is a second, simpler probe on the Console domain. Create an Error, define a getter on its stack property, then pass it to console.debug. If a CDP client with the console domain attached is serializing that object for a remote listener, the getter fires. In an ordinary browser with nothing attached, it does not:

// Run this in the page. Returns true if a CDP client is listening.
function cdpConsoleProbe() {
  let touched = false;
  const err = new Error('probe');
  Object.defineProperty(err, 'stack', {
    configurable: false,
    enumerable: false,
    get() { touched = true; return ''; },
  });
  console.debug(err);
  return touched;
}

Fixing this needs a patched client library, not a page script. The two current options:

ApproachMechanismCost
`rebrowser-patches` via `rebrowser-puppeteer` or `rebrowser-playwright`Env var `REBROWSER_PATCHES_RUNTIME_FIX_MODE` selects `addBinding` (default), `alwaysIsolated`, or `enableDisable``alwaysIsolated` loses access to main-world variables
PatchrightExecutes JS in isolated execution contexts and disables the Console API outright`console.log` inside the page stops working entirely

Patchright's Console fix is a good example of the real trade-off in this space. The only reliable way to stop leaking through an API is to remove the API, and you pay for that in debuggability.


The Maintenance Lag Problem

Here are the numbers, straight from the npm registry, as of August 2026:

PackageLatest versionPublished
`puppeteer-extra-plugin-stealth`2.11.21 March 2023
`playwright-extra`4.3.61 March 2023
`rebrowser-puppeteer`24.8.1May 2025
`patchright` (Node and Python)1.62.1August 2026

The berstend/puppeteer-extra repository has more than 7,300 stars, roughly 270 open issues, and its last push was July 2024. It is not archived and it is not abandoned in spirit, but the stealth plugin itself has not had a release in over three years.

Three years is a long time in browser terms. Chrome ships a stable major roughly every four weeks, so the binary the plugin is patching has moved forward by dozens of versions while the patches have not moved at all. That produces a specific failure mode worth naming: an evasion written to make Chrome 110 look normal, applied to Chrome 140, does not become a no-op. It becomes a new inconsistency, because it is now imitating a browser that no longer exists.

The clearest place to check this on your own build is User-Agent Client Hints. user-agent-override rewrites the classic navigator.userAgent string, while navigator.userAgentData and the Sec-CH-UA request headers are a separate surface a page can read independently. If those two disagree about the major version or the platform, you have handed a detector a single-read verdict. Log both and compare before you ship anything:

const report = await page.evaluate(() => ({
  ua: navigator.userAgent,
  uaData: navigator.userAgentData
    ? { brands: navigator.userAgentData.brands,
        mobile: navigator.userAgentData.mobile,
        platform: navigator.userAgentData.platform }
    : null,
  platform: navigator.platform,
}));
console.log(JSON.stringify(report, null, 2));

playwright-extra, rebrowser, and Patchright

The tooling has split into two philosophies. One injects scripts to fake a nicer browser. The other patches the automation client so it stops leaking, then asks you to stop faking things.

playwright-extra ports the puppeteer-extra plugin bus to Playwright, so you can load the same stealth plugin. It works, with a caveat that matters: the evasions were written against Puppeteer's launch behavior and Chromium build, and Playwright launches differently and ships its own patched Chromium. Some evasions are redundant there, and some misfire. It was last published the same day as the stealth plugin.

rebrowser-puppeteer and rebrowser-playwright are drop-in forks of the upstream libraries with the CDP patches pre-applied. They deliberately do no cosmetic JS spoofing. Swap the import, set the env var, keep the rest of your code.

Patchright is a patched Playwright distribution, published for both Node and Python and tracking Playwright releases closely. Per its project README, it avoids Runtime.enable by evaluating in isolated contexts, disables the Console API, removes --enable-automation, adds --disable-blink-features=AutomationControlled, and can also drive elements inside closed shadow roots. It patches Chromium-based browsers only, so Firefox and WebKit are out.

The most instructive thing about Patchright is its own recommended configuration, which is essentially an argument against spoofing:

const { chromium } = require('patchright');

const context = await chromium.launchPersistentContext('./profile', {
  channel: 'chrome',   // real Chrome, not the bundled Chromium
  headless: false,     // headed, or a virtual display
  viewport: null,      // let the real window size through
  // do NOT set userAgent or extra HTTP headers here
});
const page = await context.newPage();

Real Chrome, headed, real window size, no header overrides, a persistent profile that accumulates history and cookies like a used browser. Nothing is being faked. The patches only stop the automation client from announcing itself. If you have looked at antidetect browsers, you will recognize the same conclusion arrived at from the commercial side.


A Baseline Setup for Both Libraries

Install what you need, not everything at once:

# Puppeteer route
npm i puppeteer-extra puppeteer-extra-plugin-stealth rebrowser-puppeteer

# Playwright route
npm i playwright-extra puppeteer-extra-plugin-stealth
npm i patchright && npx patchright install chromium

Puppeteer with stealth plus the CDP patch, which is the combination most people should start from:

const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');

puppeteer.use(StealthPlugin());

// Run with: REBROWSER_PATCHES_RUNTIME_FIX_MODE=addBinding node scrape.js
const browser = await puppeteer.launch({
  headless: false,
  channel: 'chrome',
  args: [
    '--disable-blink-features=AutomationControlled',
    '--proxy-server=http://gateway.sparkproxy.io:11000',
  ],
});

const page = await browser.newPage();
await page.authenticate({ username: 'YOUR_USER', password: 'YOUR_PASS' });
await page.goto('https://www.sparkproxy.io/', { waitUntil: 'domcontentloaded' });

The Playwright equivalent through playwright-extra:

const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();

chromium.use(stealth);

const browser = await chromium.launch({
  headless: false,
  channel: 'chrome',
  proxy: {
    server: 'http://gateway.sparkproxy.io:11000',
    username: 'YOUR_USER',
    password: 'YOUR_PASS',
  },
});
const page = await browser.newPage();

Two things to disable immediately if you inherited a config from a blog post. Do not set a userAgent override unless you have verified Client Hints agree with it, and do not enable webgl.vendor spoofing on a machine with no real GPU. Both are net negatives on most modern targets. Full proxy wiring for each library is covered in using proxies with Puppeteer and web scraping with Playwright and proxies.


Test Your Own Setup Instead of Copying a Config

Every stealth config is target-specific and perishable. The config that worked for someone in January is a starting hypothesis, not an answer. Measure yours.

Layer 1: public panels, for catching obvious breakage only

bot.sannysoft.com, CreepJS, browserscan.net/bot-detection, and bot-detector.rebrowser.net are all worth loading once. Green across the board means you did not misconfigure something basic. It does not mean you are undetected. These pages are public, well known, and mostly test the same 2021-era property reads that carry almost no weight now.

Layer 2: your own consistency harness

This is the check that actually finds problems, because it looks for signals that contradict each other rather than signals that look wrong in isolation. Run it in your automated browser and in a real desktop Chrome, then diff the two:

const audit = await page.evaluate(() => {
  const gl = document.createElement('canvas').getContext('webgl');
  const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
  let stackLeak = false;
  try { navigator.permissions.query(); }
  catch (e) { stackLeak = /at (Object|Proxy)\.(apply|get)\b/.test(e.stack || ''); }

  return {
    webdriver: navigator.webdriver,
    ua: navigator.userAgent,
    uaDataPlatform: navigator.userAgentData && navigator.userAgentData.platform,
    platform: navigator.platform,
    languages: navigator.languages,
    cores: navigator.hardwareConcurrency,
    memory: navigator.deviceMemory,
    hasChrome: typeof window.chrome === 'object',
    pluginCount: navigator.plugins.length,
    glVendor: dbg && gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL),
    glRenderer: dbg && gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL),
    glExtensions: gl ? gl.getSupportedExtensions().length : 0,
    maxTexture: gl && gl.getParameter(gl.MAX_TEXTURE_SIZE),
    outerW: window.outerWidth,
    innerW: window.innerWidth,
    proxyStackLeak: stackLeak,
  };
});
console.log(audit);

Read the output for contradictions, not for individual values:

CheckFails when
`ua` vs `uaDataPlatform` vs `platform`Any of the three names a different OS
`glVendor` vs `glExtensions` and `maxTexture`Vendor claims a discrete GPU, the extension list is a software renderer's
`cores` vs `memory`16 cores paired with 0.5 GB of device memory
`outerW` vs `innerW`Outer is smaller than inner, or the difference is exactly 0
`pluginCount`Non-zero on a build that has no PDF viewer
`proxyStackLeak`It returns true, meaning wrapper frames survive into stack traces

Layer 3: the only number that matters

Run the same 200 URLs from your real target through two arms of an A/B: arm A with the stealth stack, arm B without it, same proxy pool, same pacing, same session handling. Record block rate, challenge rate, and median time to first byte. Change exactly one variable per run.

The result surprises people regularly. On a large share of targets, the stealth arm blocks more often, because the patched surface is what is being scored. That result is worth knowing before you build a pipeline on top of it.


Where a Clean IP Beats a Patched Browser

The network layer is evaluated first. Before a single line of your injected script runs, the edge has already seen your source IP, its ASN, its abuse history, and whether the surrounding /24 has been hammered for the last six hours. On a burned datacenter range, a flawless browser fingerprint does not save the request. On a clean residential IP with a sane request rate, a visibly automated but internally consistent browser very often gets through.

Ordered by return on effort:

LayerEffort to fixEffect on block rate
IP quality and rotation policyLow, it is a config changeLarge
Request pacing, concurrency, session reuseLowLarge
CDP leaks (rebrowser, Patchright)Low, a dependency swapMedium to large on hardened targets
TLS and HTTP/2 fingerprintMediumMedium, large against some vendors
JS property patching (stealth plugins)LowSmall, sometimes negative
Canvas, audio, and font spoofingHighSmall unless done perfectly

Most teams work that list backwards, spending a week on evasions before they have checked whether their proxy pool is the problem. Fix the top two rows first and re-measure. It is usually the whole fix.


Skip the Browser Entirely with the Scraping API

For pages that genuinely need a rendered DOM, you can hand off the browser problem instead of maintaining a patched fork yourself. The SparkProxy Scraping API runs the render and the proxy rotation for you:

import os, requests

r = requests.post(
    "https://scrape.sparkproxy.io/api/v1",
    headers={
        "X-API-Key": os.environ["SPARKPROXY_API_KEY"],  # key format: sk-...
        "Content-Type": "application/json",
    },
    json={
        "url": "https://www.sparkproxy.io/pricing",
        "render_js": True,       # 5 credits: full browser render
        "premium_proxy": True,   # residential exit IP
        "country_code": "us",
        "forward_headers": {"Accept-Language": "en-US,en;q=0.9"},
    },
    timeout=90,
)
r.raise_for_status()
html = r.text

Keep the split economic. render_js=false costs 1 credit and handles every JSON endpoint and server-rendered page, which is most of what you scrape. Reserve render_js=true at 5 credits for pages that really do assemble themselves in the browser. If you are running a fleet of patched Chrome instances just to fetch server-rendered HTML, you are paying for a browser you do not need.


Frequently asked questions

FAQ

Partly. Version 2.11.2 was published on 1 March 2023 and still removes the basic tells like navigator.webdriver and the missing window.chrome object, which is enough for lightly protected sites. Against Cloudflare, DataDome, Kasada, or PerimeterX it does not clear the bar on its own, and several of its evasions now create fingerprint contradictions rather than removing them.

Yes, and detecting the plugin is easier than detecting plain headless Chrome. The evasion source is public, the patched property set has been unchanged for three years, and the patches leave observable traces: proxy frames in error stacks, a navigator.plugins mock whose shape differs from a real PluginArray, and WebGL vendor strings that contradict the actual extension list.

It is the same evasion code loaded through playwright-extra, which was also last published in March 2023. Because the evasions were written against Puppeteer's launch behavior and Chromium build, a few are redundant or misfire under Playwright. For Playwright specifically, Patchright is the better-maintained starting point.

Use Patchright if you are on Playwright and want the fixes bundled with an actively updated distribution. Use rebrowser-puppeteer or rebrowser-playwright if you need a drop-in fork of upstream with the Runtime.enable fix and nothing else changed. Both target the CDP layer, so neither replaces a stealth plugin's JS patches, and on most targets neither needs to.

No. Stealth plugins only modify JavaScript properties inside the page plus a few launch flags. Your IP, ASN, TLS fingerprint, and request pacing are untouched, and those are evaluated before any page script executes. Pair a stealth setup with a proxy pool, or the browser work is wasted.

Load bot.sannysoft.com and bot-detector.rebrowser.net for a five-minute sanity check, then run a consistency audit that compares navigator.userAgent against navigator.userAgentData, and the WebGL vendor string against the supported extension list. After that, A/B 200 real requests from your actual target with and without the plugin and compare block rates. Only the last test tells you anything binding.


Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's datacenter, residential, and ISP proxy networks along with the SparkProxy Scraping API. We spend our days looking at block rates, challenge pages, and fingerprint mismatches across thousands of live scraping workloads, which shapes the advice here: fix the network layer before the browser layer, and never spoof a signal you cannot keep consistent. Full API reference at sparkproxy.io/docs/scraping-api.

Keep reading

Related articles

How to Scrape Yandex Search Results in 2026

How to Scrape Yandex Search Results in 2026

Scrape Yandex search results reliably: lr region codes, Cyrillic queries, SmartCaptcha detection, the official XML API, and working SparkProxy code.

SparkProxyยทGuides