Skip to content

Playwright Stealth: How It Works, Tested Setup, and When It Is Not Enough

Playwright stealth means patching a headless browser so it hides the signs of automation. The Node and Python packages, who maintains them, what they patch, tested setup code with real bot.sannysoft.com results, and where stealth stops working.

Published
Reading time
8 min read
Pixel-art robot with an orange >_ on its terminal head, disguised in a dark fedora and a raised collar, pulls a data card out of a browser window showing the Playwright logo and drops it into a crate
In this article
  1. The stealth packages at a glance
  2. What the stealth plugin patches
  3. Playwright stealth in Node.js
  4. playwright-stealth in Python
  5. What our test showed
  6. Match the fingerprint to your proxy's location
  7. Puppeteer stealth
  8. When Playwright stealth is not enough
  9. The hosted answer: the Scrape.ly browser
  10. FAQ
  11. Does playwright-stealth still work?
  12. Is playwright-stealth for Python maintained?
  13. Why does from playwright_stealth import stealth_async fail?
  14. Is there a stealth plugin for Playwright in Node.js?
  15. Does stealth work with Firefox or WebKit?
  16. How do I test whether my Playwright scraper looks automated?
  17. Is using a stealth plugin legal?

Playwright stealth is the practice of patching a Playwright browser so it hides the obvious signs that a script is driving it, such as navigator.webdriver being true or "HeadlessChrome" in the User-Agent. In Node.js you get it from playwright-extra with the puppeteer-extra-plugin-stealth plugin; in Python, from the playwright-stealth package on PyPI.

Below: who maintains each package and when they last shipped (checked on npm, PyPI and GitHub on 23 October 2026), what the evasions actually patch, setup code we tested in both languages with the results it produced, and the detection it can't touch. At the end, the hosted alternative that makes the plugin unnecessary.

The stealth packages at a glance

Node.js Python
Packages playwright-extra 4.3.6 + puppeteer-extra-plugin-stealth 2.11.2 playwright-stealth 2.0.3
Last release 1 March 2023 (both) 4 April 2026
Maintainer berstend, in the puppeteer-extra monorepo Mattwmaster58, a fork of AtuboDad's Python port
Repository activity Last push 18 July 2024, 277 open issues Last push 4 April 2026
License MIT MIT
Evasions 17 modules 19 switches
Works with Playwright (Chromium) and Puppeteer Playwright for Python, Python 3.9+

Two things stand out. The Node plugin, the one most tutorials use, hasn't had a release in more than three and a half years. And the Python package changed its API in version 2.0, so most Python tutorials online no longer run as written (more on that below).

What the stealth plugin patches

A default headless Chromium differs from a normal Chrome in dozens of small ways that a page's JavaScript can read. The stealth evasions overwrite them before any page script runs. Grouped by what they hide:

What gives headless away Evasions (Node module / Python switch)
The automation flag navigator.webdriver / navigator_webdriver
Launch flags a normal browser doesn't use, such as --disable-extensions defaultArgs (Node); the Python package patches launch options through use_async / use_sync
"HeadlessChrome" in the User-Agent and client hints user-agent-override / navigator_user_agent, navigator_user_agent_data, sec_ch_ua
Missing window.chrome objects chrome.app, chrome.csi, chrome.loadTimes, chrome.runtime (same in Python)
Empty or odd browser APIs navigator.plugins, navigator.permissions, navigator.languages, navigator.vendor, navigator.hardwareConcurrency, media.codecs (Python adds navigator_platform)
The software GPU webgl.vendor / webgl_vendor
Window and frame quirks window.outerdimensions, iframe.contentWindow / iframe_content_window, hairline
Script traces sourceurl / error_prototype

All of this happens in JavaScript and request headers. Nothing in either package touches your IP address, your TLS handshake or how your script behaves on the page.

Playwright stealth in Node.js

Install Playwright, the wrapper and the plugin:

shell
npm install playwright playwright-extra puppeteer-extra-plugin-stealth
npx playwright install chromium

playwright-extra is a drop-in replacement for Playwright's chromium export that accepts plugins:

javascript
import { chromium } from 'playwright-extra';
import stealth from 'puppeteer-extra-plugin-stealth';

chromium.use(stealth());

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const result = await page.evaluate(() => ({
  webdriver: navigator.webdriver,
  userAgent: navigator.userAgent,
  failedOrWarn: document.querySelectorAll('td.failed, td.warn').length,
}));
console.log(result);
await browser.close();

bot.sannysoft.com is a public fingerprint test page that marks each check as passed, warned or failed. The script counts the failures.

playwright-stealth in Python

Install with pip install playwright playwright-stealth, then playwright install chromium. Version 2 wraps the whole Playwright context manager, so every page from every context gets the patches:

python
import asyncio

from playwright.async_api import async_playwright
from playwright_stealth import Stealth

CHECK = """() => ({
    webdriver: navigator.webdriver,
    userAgent: navigator.userAgent,
    failedOrWarn: document.querySelectorAll('td.failed, td.warn').length,
})"""


async def main():
    async with Stealth().use_async(async_playwright()) as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        await page.goto("https://bot.sannysoft.com", wait_until="networkidle")
        await page.wait_for_timeout(2000)
        print(await page.evaluate(CHECK))
        await browser.close()


asyncio.run(main())

For the sync API, use Stealth().use_sync(sync_playwright()). You can switch evasions on and off with keyword arguments, for example Stealth(navigator_languages_override=("fr-FR", "fr")).

If you're following an older tutorial: from playwright_stealth import stealth_async (and stealth_sync) was the 1.x API. In 2.0.3 that import fails with ImportError: cannot import name 'stealth_async'. The changelog lists 2.0.0 as a breaking change.

What our test showed

We ran both scripts on 23 October 2026 on macOS: Node with Playwright 1.64.1, Python with Playwright 1.64.0, both driving headless Chromium 156. Each was run once without stealth and once with it. Node and Python gave identical results:

Check Plain headless Playwright With stealth
navigator.webdriver true false
"HeadlessChrome" in User-Agent Yes No
navigator.plugins.length 0 3
navigator.languages en-US en-US,en
window.chrome missing present
bot.sannysoft.com rows passed 18 31
bot.sannysoft.com rows failed or warned 13 0

The 13 flagged rows without stealth included the WebDriver check, the missing Chrome object, the permissions check, the plugin checks and the "HEADCHR" headless tests. With stealth loaded, every row passed.

That's a real improvement, and it's also the ceiling of what a test page like this measures. Passing bot.sannysoft.com means your browser's JavaScript surface looks normal. It doesn't mean a commercial anti-bot system will let you through.

Match the fingerprint to your proxy's location

Stealth makes a browser look normal. It doesn't make it look like it lives where your IP address does. If you scrape through a German proxy, the browser's language and time zone should be German too, and Playwright sets both per context. Here the stealth defaults get in the way. In our test, a context created with locale="de-DE" sent Accept-Language: de-DE, but with the Python package's default settings, page scripts saw navigator.languages as ['en-US', 'en']. Without stealth it was ['de-DE']. Two signals that disagree are worse than one that's honest.

The fix is to tell the package which languages to report:

python
import asyncio

from playwright.async_api import async_playwright
from playwright_stealth import Stealth


async def main():
    stealth = Stealth(navigator_languages_override=("de-DE", "de"))
    async with stealth.use_async(async_playwright()) as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            locale="de-DE",
            timezone_id="Europe/Berlin",
            # proxy={"server": "http://proxy.example:8000", "username": "user", "password": "pass"},
        )
        page = await context.new_page()
        await page.goto("https://quotes.toscrape.com/")
        print(await page.evaluate(
            "() => [navigator.languages, Intl.DateTimeFormat().resolvedOptions().timeZone]"
        ))
        await browser.close()


asyncio.run(main())

With the override it printed [['de-DE', 'de'], 'Europe/Berlin'], consistent with the Accept-Language header. Each context can also take its own proxy, so one browser can run several identities, each with matching IP, language and time zone. Re-run a check like this after every Playwright or plugin upgrade: the patches target properties that change between Chrome versions.

Puppeteer stealth

The plugin started life in Puppeteer, and setup there is the same idea with puppeteer-extra:

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, 3000));
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();

With Puppeteer 25.15.0 and puppeteer-extra 3.3.6 (pointed at Chrome for Testing 156 in our test), it printed { webdriver: false, passed: 31, failedOrWarn: 0 }, the same result as Playwright. If you're choosing between the two libraries for scraping, see Playwright vs Puppeteer; for the Selenium comparison, including a navigator.webdriver test of both, see Playwright vs Selenium.

When Playwright stealth is not enough

The plugin's own docs are honest about this. The Node README calls it "a rather interesting cat and mouse game" and says "it's probably impossible to prevent all ways to detect headless chromium". The Python README goes further: "Don't expect this to bypass anything but the simplest of bot detection methods."

Here is what stealth can't fix:

  • Your IP address. If you scrape from a cloud server, every request comes from a datacenter range. Anti-bot systems weigh where traffic comes from, and no JavaScript patch changes that. You need good proxies; see residential vs datacenter proxies.
  • Your TLS handshake. Systems like Cloudflare fingerprint the TLS connection itself (JA3/JA4), before any page script runs.
  • Consistency. Every patch is a lie that has to match every other signal. One example from our test: both packages rewrite the sec-ch-ua client-hint header, but to different brand strings than the same unpatched Chromium 156 sends (" Not A;Brand";v="99" from the Node plugin and "Not.A-Brand";v="99" from the Python package, versus "Not:A-Brand";v="8" without stealth). A detector that knows what each Chrome version really sends can spot mismatches like that.
  • Behavior. Request rates, navigation patterns and how a session moves through a site are judged separately from the fingerprint. Cloudflare, for example, scores "session characteristics" alongside browser signals (bot scores).
  • Maintenance. Detection changes all the time. A plugin whose last release was in March 2023 is patching the Chrome of 2023.

Our guide to web scraping without getting blocked covers the other layers: pacing, sessions, proxies and challenge handling.

The hosted answer: the Scrape.ly browser

Instead of patching a headless browser yourself, run your Playwright code on a browser that is built to look like a visitor. The Scrape.ly browser is a hosted Chromium with anti-bot fingerprinting, 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), rebuild, and every browser launch in your code connects to it over CDP, unchanged. To set the location and the operating system the fingerprint presents, add a browser configuration. In Python:

python
import asyncio

from crawlee.crawlers import PlaywrightCrawler
from scrapely import Actor


async def main():
    browser_configuration = await Actor.create_browser_configuration(
        country_code="US", state="California", os="windows"
    )
    crawler = PlaywrightCrawler(browser_configuration=browser_configuration)
    await crawler.run(["https://example.com"])


asyncio.run(Actor.main(main))

Time zone and language follow the exit country unless you set them, which keeps the fingerprint consistent with the IP, the exact problem stealth plugins can't solve on their own. You can choose Windows, macOS or Android. It costs $0.45 per browser hour at list price, billed in 30-second blocks plus the browser's own traffic, and it counts toward the run's maximum charge. The anti-blocking page shows how it fits with the managed proxy.

To start from working code, the Crawlee + Playwright (Python) template gives you a PlaywrightCrawler project you can push as an Actor. And if your target is a popular site, check the Scrape.ly Store first: 568 ready-made Actors, maintained by their publishers. For crawling public pages into text, the Website Content Crawler opens Chrome only when a page needs JavaScript.

FAQ

Does playwright-stealth still work?

It still does what it says: in our 23 October 2026 test, both the Node plugin and the Python package made headless Chromium 156 pass every check on bot.sannysoft.com. Against commercial anti-bot systems it is a partial fix, because it doesn't change your IP, TLS handshake or behavior.

Is playwright-stealth for Python maintained?

Yes. The PyPI package is maintained by Mattwmaster58 as a fork of AtuboDad's original port; version 2.0.3 shipped on 4 April 2026 and added a navigator.userAgentData evasion. The original AtuboDad repository was last pushed in July 2024.

Why does from playwright_stealth import stealth_async fail?

Because version 2.0 replaced that API. Use Stealth().use_async(async_playwright()) or Stealth().use_sync(sync_playwright()), or await Stealth().apply_stealth_async(context) to patch a single context.

Is there a stealth plugin for Playwright in Node.js?

Not a dedicated one. You use playwright-extra with the Puppeteer stealth plugin, puppeteer-extra-plugin-stealth, which works with both. Both packages last shipped on 1 March 2023.

Does stealth work with Firefox or WebKit?

Our tests used Chromium only, and many evasions target Chrome-specific properties such as window.chrome and Chrome's client hints. If you need a different engine, test it yourself before relying on it.

How do I test whether my Playwright scraper looks automated?

Load bot.sannysoft.com with the scripts above and count the failed rows. Then log the request headers your browser sends (request.all_headers() in Playwright) and compare navigator.languages and the time zone with your proxy's country. Only after that, try your target at a low request rate and watch for challenge pages.

Changing how your own browser presents itself is not 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.

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

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. The advantage lasts forever.

No credit card. No setup calls.