Skip to content

Puppeteer Stealth: Setup, What It Patches, and Where It Stops

Puppeteer stealth is puppeteer-extra-plugin-stealth, a plugin that hides the signs of a headless Chrome. Its maintenance status in October 2026, tested setup with Puppeteer 25, before-and-after results, the header mismatches it creates, a Crawlee setup and the hosted alternative.

Published
Reading time
8 min read
Pixel-art robot with an orange >_ on its terminal head, disguised in a dark fedora, a raised collar and a cloak, pulls a data card out of a browser window showing the green Puppeteer logo and drops it into a crate
In this article
  1. puppeteer-extra-plugin-stealth at a glance
  2. Install and set up Puppeteer stealth
  3. What the stealth plugin patches
  4. What our test showed
  5. The mismatches stealth creates
  6. Puppeteer stealth with Crawlee
  7. Where Puppeteer stealth stops
  8. The hosted alternative: Puppeteer on the Scrape.ly browser
  9. FAQ
  10. Is puppeteer-extra-plugin-stealth still maintained?
  11. Does Puppeteer stealth work with Puppeteer 25?
  12. Does Puppeteer stealth bypass Cloudflare?
  13. Do I still need stealth with Chrome's new headless mode?
  14. Can I turn off a single evasion?
  15. Is using Puppeteer stealth legal?

Puppeteer stealth is puppeteer-extra-plugin-stealth, a plugin for puppeteer-extra that patches headless Chrome so page scripts can't easily tell it's automated. It still works with today's Puppeteer, but you should know up front that it's unmaintained: its last release, 2.11.2, shipped on 1 March 2023.

Below: the plugin's status as checked on npm and GitHub on 26 October 2026, a setup we tested with Puppeteer 25.16.0 and Chrome 156, what each evasion patches, before-and-after results in both of Chrome's headless modes, the header mismatches the plugin introduces and how to fix them, how to use it with Crawlee, and the hosted alternative that makes it unnecessary.

puppeteer-extra-plugin-stealth at a glance

Package puppeteer-extra-plugin-stealth 2.11.2, used with puppeteer-extra 3.3.6
Last release 1 March 2023 (both packages)
Repository berstend/puppeteer-extra, a monorepo; last push 18 July 2024, 277 open issues, not archived
Downloads 769,084 in the week of 16 to 22 October 2026 (npm)
License MIT
Evasions 17 modules
Works with Puppeteer, and Playwright through playwright-extra

So, is Puppeteer stealth still maintained? Not actively. Nobody has published a release in more than three and a half years, while Chrome has shipped dozens of versions. It's still downloaded hundreds of thousands of times a week, and as our test shows, its patches still take effect. Treat it as a useful starting point you'll have to watch, not as a maintained product.

Install and set up Puppeteer stealth

shell
npm install puppeteer puppeteer-extra puppeteer-extra-plugin-stealth

puppeteer-extra wraps Puppeteer and adds a plugin system; you import it instead of puppeteer, register the plugin, and launch as usual:

javascript
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('https://bot.sannysoft.com', { waitUntil: 'networkidle2' });
await new Promise((r) => setTimeout(r, 6000));

const result = await page.evaluate(() => ({
  webdriver: navigator.webdriver,
  passed: document.querySelectorAll('td.passed').length,
  failedOrWarn: document.querySelectorAll('td.failed, td.warn').length,
}));
console.log(result);
await browser.close();

bot.sannysoft.com is a public test page that runs common headless checks and marks each row as passed, warned or failed. The six-second wait matters: the page fills its second table late, and with a three-second wait we sometimes counted only 11 rows instead of 31. The examples use ES modules ("type": "module" in package.json); in a CommonJS project, require('puppeteer-extra') and require('puppeteer-extra-plugin-stealth') load the same code.

To check your own setup, run this script, count the failed rows, then compare the Accept-Language header, navigator.languages and the time zone with your proxy's country. Re-run both checks after every Puppeteer or Chrome upgrade.

What the stealth plugin patches

The plugin is a bundle of 17 evasion modules. Each overwrites one thing a headless Chrome gets wrong, before any page script runs:

Evasion What it fixes
navigator.webdriver The automation flag that is true in every automated Chrome
user-agent-override Removes "HeadlessChrome" from the User-Agent, sets Accept-Language and client hints
chrome.app, chrome.csi, chrome.loadTimes, chrome.runtime Chrome-only objects that the headless shell lacks
navigator.plugins, navigator.languages, navigator.vendor, navigator.hardwareConcurrency, navigator.permissions Browser APIs that return empty or odd values in headless mode
webgl.vendor The software GPU name
media.codecs Codec support that differs from regular Chrome
window.outerdimensions A window with no outer size
iframe.contentWindow Frame-based headless checks
defaultArgs Launch flags a normal browser doesn't use
sourceurl Traces Puppeteer leaves in script URLs

Everything here happens in JavaScript and request headers. Nothing touches your IP address, your TLS handshake or how your script moves through a site.

What our test showed

We ran the script above on 26 October 2026 on macOS, with Puppeteer 25.16.0 driving Chrome for Testing 156, once without the plugin and once with it, in both of Chrome's headless modes: the new headless mode (headless: true, Puppeteer's default) and the old headless shell (headless: 'shell').

Check New headless New headless + stealth Headless shell Headless shell + stealth
navigator.webdriver true false true false
"HeadlessChrome" in User-Agent Yes No Yes No
navigator.plugins.length 5 5 0 3
window.chrome present present missing present
bot.sannysoft.com rows failed or warned 4 0 13 0

Two takeaways. First, the plugin does what it says: zero flagged rows in both modes. Second, Chrome's new headless mode already does much of the work. Without any plugin it failed only four checks (two User-Agent tests, WebDriver and a memory test), against 13 for the shell. If you still launch with headless: 'shell' or an old Puppeteer, switching to the default headless mode is the cheapest improvement you can make.

The mismatches stealth creates

Passing a test page means the JavaScript surface looks normal. It doesn't mean every signal agrees, and the plugin introduces a few disagreements of its own. We loaded a header echo service with each setup and compared what Chrome sent:

Signal New headless, no plugin New headless + stealth
Accept-Language header de-DE,de;q=0.9 (the machine's language) en-US,en;q=0.9
navigator.languages de-DE en-US,en
Time zone Europe/Zurich Europe/Zurich
Sec-CH-UA header "Not:A-Brand";v="8", "Chromium";v="156" " Not A;Brand";v="99", "Chromium";v="156", "Google Chrome";v="156"

The language now claims US English while the time zone stays in Switzerland. And the plugin's Sec-CH-UA brand string uses an older format with version 99, not what this Chrome 156 sends on its own. A detector that knows what each Chrome version really sends can spot both.

You can fix the language half. Disable the two default evasions that set it and register them again with your own values, then set the time zone to match your proxy:

javascript
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import UserAgentOverride from 'puppeteer-extra-plugin-stealth/evasions/user-agent-override/index.js';
import NavigatorLanguages from 'puppeteer-extra-plugin-stealth/evasions/navigator.languages/index.js';

const stealth = StealthPlugin();
stealth.enabledEvasions.delete('user-agent-override');
stealth.enabledEvasions.delete('navigator.languages');
puppeteer.use(stealth);
puppeteer.use(UserAgentOverride({ locale: 'de-DE,de' }));
puppeteer.use(NavigatorLanguages({ languages: ['de-DE', 'de'] }));

const browser = await puppeteer.launch({
  headless: true,
  // args: ['--proxy-server=http://proxy.example:8000'],
});
const page = await browser.newPage();
await page.emulateTimezone('Europe/Berlin');
await page.goto('https://httpbin.org/headers');
const { headers } = JSON.parse(await page.evaluate(() => document.body.innerText));
console.log({
  acceptLanguage: headers['Accept-Language'],
  languages: await page.evaluate(() => navigator.languages),
  timeZone: await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone),
  webdriver: await page.evaluate(() => navigator.webdriver),
});
await browser.close();

It printed acceptLanguage: 'de-DE,de;q=0.9', languages: [ 'de-DE', 'de' ], timeZone: 'Europe/Berlin' and webdriver: false: one consistent German visitor, ready for a German proxy. The same enabledEvasions.delete() call turns off any evasion that breaks a site you need. The brand string has no option; it's hard-coded in a plugin that hasn't shipped since 2023.

Puppeteer stealth with Crawlee

Crawlee adds queues, retries, sessions and proxy rotation on top of Puppeteer, and it accepts puppeteer-extra as its launcher:

javascript
import { PuppeteerCrawler } from 'crawlee';
import puppeteerExtra from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';

puppeteerExtra.use(StealthPlugin());

const crawler = new PuppeteerCrawler({
    launchContext: { launcher: puppeteerExtra, launchOptions: { headless: true } },
    maxRequestsPerCrawl: 3,
    async requestHandler({ page, request, enqueueLinks }) {
        const webdriver = await page.evaluate(() => navigator.webdriver);
        console.log(request.url, await page.title(), { webdriver });
        await enqueueLinks({ selector: 'li.next a' });
    },
});

await crawler.run(['https://books.toscrape.com/']);

With Crawlee 3.18.2 it crawled the first three catalog pages of the books.toscrape.com sandbox, each with { webdriver: false }. Set headless: true explicitly in launchOptions; in our test the crawler stalled at start without it. Crawlee also brings the next layer of anti-blocking, proxy rotation with sessions, which our guide to rotating proxies for web scraping covers with tested code.

Where Puppeteer stealth stops

The plugin's author is candid about it in the README: "It's probably impossible to prevent all ways to detect headless chromium", and the project calls it "a rather interesting cat and mouse game". Here is what stealth can't fix:

  • Your IP address. A scraper on a cloud server sends every request from a datacenter range. No JavaScript patch changes that; you need good proxies, ideally residential on protected sites.
  • Your TLS handshake. Anti-bot systems fingerprint the TLS connection itself, before any page script runs.
  • CAPTCHAs. Stealth lowers the odds of a challenge; it doesn't solve one. Our CAPTCHA bypass guide covers prevention and what solving services involve.
  • Behavior. Request rate, navigation order and session patterns are scored separately from the fingerprint.
  • Time. Detection keeps changing, and this plugin patches the Chrome of 2023. Re-test after every Puppeteer or Chrome upgrade.

If you use Playwright, the same plugin works through playwright-extra, and Python has its own package; see Playwright stealth. For the broader picture, read what a headless browser is and web scraping without getting blocked.

The hosted alternative: Puppeteer on the Scrape.ly browser

Instead of patching headless Chrome yourself and keeping the patches current, run your Puppeteer code on a browser built to look like a visitor. The Scrape.ly browser is a hosted Chromium with anti-bot fingerprints, its own residential proxy and Cloudflare Turnstile solving, on by default. You don't need a stealth plugin.

You turn it on per Actor (Settings → Scrapely Browser) and rebuild. From then on every puppeteer.launch() and every Crawlee PuppeteerCrawler in the Actor connects to it over CDP, without code changes. To pick the location and operating system the fingerprint presents, pass a browser configuration to the crawler:

javascript
import { Actor, PuppeteerCrawler } from 'scrapely';

await Actor.main(async () => {
    const browserConfiguration = await Actor.createBrowserConfiguration({
        countryCode: 'DE',
        os: 'windows',
    });

    const crawler = new PuppeteerCrawler({
        browserConfiguration,
        async requestHandler({ page }) {
            await Actor.pushData({ title: await page.title() });
        },
    });
    await crawler.run(['https://example.com']);
});

Time zone and language follow the exit country unless you set them, which solves the mismatch from our test without a line of override code. Code that connects itself can use the run's CDP_URL with puppeteer.connect({ browserWSEndpoint: process.env.CDP_URL }). It costs $0.45 per browser hour at list price, billed in 30-second blocks plus the browser's traffic, and it counts toward the run's maximum charge. What is a cloud browser explains how hosted browsers like this one work.

To start from working code, the Crawlee + Puppeteer template gives you a project you can push as an Actor. And if your target is a popular site, check the Scrape.ly Store first: 570+ ready-made Actors, maintained by their publishers. For crawling public pages into text, the Website Content Crawler fetches over HTTP with a Chrome TLS fingerprint and opens Chrome only when a page needs JavaScript.

FAQ

Is puppeteer-extra-plugin-stealth still maintained?

Not actively. The latest version, 2.11.2, was published on 1 March 2023, and the repository's last push was on 18 July 2024, with 277 open issues. It isn't archived and still works, but no one is updating its patches for new Chrome versions.

Does Puppeteer stealth work with Puppeteer 25?

Yes. In our 26 October 2026 test, Puppeteer 25.16.0 with puppeteer-extra 3.3.6 and the stealth plugin passed every check on bot.sannysoft.com with Chrome 156, in both the new headless mode and the headless shell.

Does Puppeteer stealth bypass Cloudflare?

Not reliably. It hides JavaScript tells, but Cloudflare also scores your IP, TLS fingerprint and behavior, and the plugin changes none of those. A hosted browser with a residential IP and Turnstile solving, like the Scrape.ly browser above, is the more reliable route on public pages.

Do I still need stealth with Chrome's new headless mode?

For most anti-bot checks, yes. The new headless mode removed most of the old tells, but in our test it still reported navigator.webdriver as true and "HeadlessChrome" in its User-Agent, two of the easiest signals for a page to read. The plugin hides both; a hosted browser like the Scrape.ly browser avoids them without patching.

Can I turn off a single evasion?

Yes. Call stealth.enabledEvasions.delete('evasion-name') before puppeteer.use(stealth), using a name from the table above. That's also how you replace an evasion with a configured copy, as in the language example.

Changing how your own browser presents itself isn't illegal in itself. What you scrape and how still matters: stay on public pages, respect robots.txt, the site's terms and rate limits, and avoid personal data you don't need.

  • puppeteer
  • stealth
  • headless browser
  • anti-blocking
  • crawlee

About the author

The team behind Scrape.ly

Scrape.ly is the web data platform from TeraShift GmbH in Switzerland, powered by Evomi: ready-made scrapers in the Scrape.ly Store, a cloud runtime, an API with JavaScript and Python SDKs, proxies, a hosted anti-bot browser and an MCP server for AI agents.

//Keep reading\\

The internet is full of data.
Start owning yours.

Scrape.ly gives you the infrastructure, the tooling, and the team to extract, process, and operationalize web data at any scale. Setup takes minutes.

No credit card. No setup calls.