· Updated
Playwright stealth plugin: what it can't hide
What does the playwright stealth plugin do? It patches leaks like navigator.webdriver, but not TLS, IP or behavior. Learn to migrate to v2 and test it.
- playwright
- scraping
- fingerprinting
- browsers
On this page
- What does the playwright stealth plugin change in the browser?
- Which repo and version should you trust, and why do old tutorials break?
- How do you migrate from stealth_async to playwright-stealth 2.x?
- How do you test what stealth fixes and what it leaves behind?
- What can stealth patches never hide from an anti-bot system?
- Is the playwright stealth plugin enough, or do you need something else?
- Is using a stealth plugin for scraping allowed?
- Frequently asked questions
- Why does stealth_async raise an AttributeError in playwright-stealth 2.x?
- Does the playwright stealth plugin work in Firefox and WebKit?
- Can playwright-stealth hide my TLS fingerprint?
- Is playwright-stealth the same as puppeteer-extra-plugin-stealth?
- What to do next, in order
- Sources
The Playwright stealth plugin injects JavaScript patches (for navigator.webdriver, the HeadlessChrome user agent and similar leaks) before page scripts run, so it passes simple checks. It doesn’t change your TLS fingerprint (JA3/JA4), IP reputation, behavior or hardware-level WebGL output. The current PyPI release is 2.0.3, and v1 code that calls stealth_async(page) needs migrating to the 2.x API.
You copy stealth_async(page) from a tutorial, install the current package, and the script can fail with an AttributeError. The Granitosaurus and AtuboDad READMEs still show that v1 pattern (stealth_async, stealth_sync), while PyPI says the latest major version includes breaking changes.
Fixing the import is only half the problem. The playwright stealth plugin leaves several detection layers untouched. If you run Playwright for public-data collection, monitoring or testing and keep getting blocked, you can migrate to v2, test what the patches change, and decide whether the plugin is enough.
Key Takeaways
- Tutorial age predicts whether code runs: the v1 READMEs use
stealth_async(page)andstealth_sync(page), while PyPI warns the latest major version includes breaking changes.- PyPI lists 2.0.3 as current. Pin it, and check the
chrome.runtimedefault in the project docs before you rely on it.- The AtuboDad README loops over Chromium, Firefox and WebKit, but gives no results. Test your own browser and version.
What does the playwright stealth plugin change in the browser?
The playwright stealth plugin is a Python package that injects JavaScript evasion scripts into a Playwright browser context before page scripts run. It patches browser-visible signals such as navigator.webdriver and the HeadlessChrome user agent. It does not change the network layer, the IP address or how the session behaves.

Playwright provides the hook for this. Its add_init_script docs say the script runs “but before any of its scripts were run”, so a patch can change a property before a detector’s code reads it.
The package is a fork of an older port. PyPI describes it as a “Fork of AtuboDad’s port of puppeteer-extra-plugin-stealth”. The evasion list comes from that Node project, and the next section covers the lineage.
ZenRows names these patched signals:
navigator.webdriver: a detector reads this flag to learn whether automation controls the browser.- The user agent: a detector looks for the
HeadlessChrometoken in the string. - Plugin arrays: a detector reads
navigator.plugins, and an empty list is a headless sign.
For languages, hardwareConcurrency and chrome.runtime, read the plugin source for the exact defaults before you rely on them.
The consequence follows from the mechanism. Everything the plugin changes lives inside the page’s JavaScript runtime. The TLS handshake, the exit IP and mouse timing happen elsewhere, so no init script can touch them.
Patching a property to a plausible value is also a claim, not proof. A detector can check whether that claim agrees with other signals, such as a Windows user agent sent from a Linux TLS stack.
Which repo and version should you trust, and why do old tutorials break?
Trust the playwright-stealth package on PyPI, which lists 2.0.3 as current, because the GitHub repos most tutorials copy from still show the v1 API.
The lineage starts with puppeteer-extra-plugin-stealth for Node. The AtuboDad/playwright_stealth repo says it was “Transplanted from puppeteer-extra-plugin-stealth, Not perfect.” The PyPI page calls the current package a “Fork of AtuboDad’s port of puppeteer-extra-plugin-stealth, with some improvements.”
The Granitosaurus/playwright-stealth repo is another variant. Its snippet calls browser.newPage(), which is camelCase. Python Playwright uses new_page(), so that snippet fails as written.
PyPI’s release history lists 1.0.5 (Mar 2021), 1.0.6 (Sep 2023), 2.0.0 (Jun 2025), 2.0.1 (Jan 2026), 2.0.2 (Feb 2026) and 2.0.3 (Apr 4, 2026, Python >=3.9). The GitHub pages show no version or date, so check PyPI before you trust any date here.
| Version | Release date | API | What tutorials of that era look like |
|---|---|---|---|
| 1.0.5 | Mar 2021 | v1 | stealth_sync(page) or stealth_async(page) |
| 1.0.6 | Sep 2023 | v1 | Same calls, copied from the READMEs |
| 2.0.0 | Jun 2025 | v2 | Breaking changes; v1 code stops working |
| 2.0.1, 2.0.2 | Jan 2026, Feb 2026 | v2 | Few tutorials; mostly PyPI text |
| 2.0.3 | Apr 4, 2026 | v2 | PyPI example with asyncio.run(main()) |
PyPI warns that “The latest major version includes breaking changes.” A tutorial that imports stealth_async was written for 1.x.
The PyPI page also admits the limit: “Don’t expect this to bypass anything but the simplest of bot detection methods.”
Pin the version in requirements.txt, and confirm the number on PyPI on the day you install:
playwright-stealth==2.0.3 How do you migrate from stealth_async to playwright-stealth 2.x?
You migrate from stealth_async to playwright-stealth 2.x by replacing the per-page helper calls with a Stealth object that either wraps Playwright or patches a context. The PyPI page warns that “The latest major version includes breaking changes.” It doesn’t spell them out on the page, so check the project changelog it links to for the full list.
1. Find the legacy v1 pattern. The Granitosaurus README uses from playwright_stealth import stealth_async, and the AtuboDad README uses from playwright_stealth import stealth_sync. Both patch one page after you create it.
# LEGACY (1.x). Fails on 2.x.
from playwright_stealth import stealth_async, stealth_sync
page = await browser.new_page()
await stealth_async(page) # async
page = browser.new_page()
stealth_sync(page) # sync 2. Recognize the symptom. Against 2.x, the old function names are gone. Depending on how you import, Python raises an ImportError or an AttributeError naming stealth_async or stealth_sync. None of the sources publishes the exact message, so read your own traceback.
3. Rewrite the async code. Wrap async_playwright() in the Stealth().use_async(...) context manager. Then call new_page() as usual.
import asyncio
from playwright.async_api import async_playwright
from playwright_stealth import Stealth
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/")
await page.screenshot(path="stealth.png", full_page=True)
await browser.close()
asyncio.run(main()) 4. Or patch a context. Call apply_stealth_async(context) before you create pages, so every page inherits the patches. Playwright’s docs describe add_init_script as a script evaluated “whenever a page is created in the browser context or is navigated”, which is why context-level setup comes first.
async with async_playwright() as p:
browser = await p.chromium.launch()
context = await browser.new_context()
await Stealth().apply_stealth_async(context)
page = await context.new_page() 5. Rewrite the sync code. The sync names mirror the async ones (use_sync, apply_stealth_sync). The PyPI text shows async examples only, so confirm both names in the repo for your pinned version.
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth
with Stealth().use_sync(sync_playwright()) as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://bot.sannysoft.com/")
browser.close() 6. Or pin 1.x. If you can’t migrate yet, run pip install "playwright-stealth<2". Your old code runs again, but you stay on the API the PyPI page says changed.
If you use launch_persistent_context, check the Todo list on the PyPI page before relying on it.
How do you test what stealth fixes and what it leaves behind?
You test what stealth fixes by loading the same detection page twice per browser, once bare and once patched, then writing each row into a matrix pinned to exact versions. The Granitosaurus README uses the same pattern: it opens https://bot.sannysoft.com/ and saves page.screenshot(path='chrome_headless_stealth.png', fullPage=True). This script adds the bare run and all three engines.
- Pin and record versions. Run
pip install playwright-stealth==2.0.3, thenplaywright install, thenpip show playwright-stealth playwright. Write the plugin version, Playwright version, OS and browser at the top of your results. - Run the script. It launches a fresh browser for every run. Reusing one browser for both runs is the common mistake, because patched state can carry over.
import asyncio
from playwright.async_api import async_playwright
from playwright_stealth import Stealth
URL = "https://bot.sannysoft.com/"
async def shot(pw, engine, out):
browser = await getattr(pw, engine).launch()
page = await browser.new_page()
await page.goto(URL)
await page.screenshot(path=out, full_page=True)
await browser.close()
async def main():
for engine in ["chromium", "firefox", "webkit"]:
async with async_playwright() as pw:
await shot(pw, engine, f"{engine}-bare.png")
async with Stealth().use_async(async_playwright()) as pw:
await shot(pw, engine, f"{engine}-stealth.png")
asyncio.run(main()) - Compare row by row. Open
chromium-bare.pngandchromium-stealth.pngside by side. Read each row’s label and value on the page itself, and note which values changed between the two files. - Add a second page. Open the CreepJS repository (
github.com/abrahamjuliot/creepjs) and read its README for what each section reports. Don’t treat any summary score as a verdict. - Fill the matrix. Make one copy per plugin version and per mode (bare or stealth). Type the value you read, not a pass or fail guess.
| Check | Chromium | Firefox | WebKit |
|---|---|---|---|
navigator.webdriver | |||
| User agent | |||
| Languages | |||
| WebGL vendor and renderer | |||
| Plugins |
The mechanism sets what to expect. These pages read JavaScript-visible properties, so those values can differ between your two runs. TLS fingerprint, IP address and behavior never appear in their output, so an unchanged row says nothing about them. The PyPI page itself warns: “Don’t expect this to bypass anything but the simplest of bot detection methods.”
A matrix you can diff as text is easier to check than a screenshot, so publish yours that way.
What can stealth patches never hide from an anti-bot system?
Stealth patches can’t hide anything decided before or outside the page’s JavaScript: your TLS handshake, your behavior, your graphics hardware and your IP address.
- TLS fingerprint (JA3 and JA4). The salesforce/ja3 repository defines JA3 as “a method for creating SSL/TLS client fingerprints”, and FoxIO-LLC/ja4 describes JA4+ as “a suite of network fingerprinting” standards. RFC 8446 says a client is “REQUIRED to send the ClientHello as its first” message, before any page script exists, so an init script can’t change it.
- Behavior. If a detector scores mouse paths, gaps between actions and navigation order, patching
navigator.webdriverchanges none of that. Your script’s own timing is what gets measured. Log pointer events and timestamps on a test page to see what you actually send. - WebGL hardware signals. MDN says the
WEBGL_debug_renderer_infoextension “exposes two constants with information about the graphics driver for debugging purposes”. A patch can return a different string for those constants, but the rendered pixels come from the real GPU or software renderer. Draw one scene in two environments and compare the output against the claimed renderer string. - IP reputation. The address belongs to the network connection, not the browser, so no script in the page can reach it. Run the same patched script from two networks and compare the results.
- Package gaps. The PyPI page mentions
launch_persistent_contextas a Todo item, so verify it against the latest changelog before you rely on it. It also lists achrome_runtimeoption, which is off by default in 2.x. The AtuboDad README’s example loops overfor browser_type in [ p . chromium , p . firefox , p . webkit ], but it publishes no per-browser results.
Treat Firefox and WebKit as untested until you run them through the matrix from the previous section.
The package says this itself. The PyPI page warns: “Don’t expect this to bypass anything but the simplest of bot detection methods.”
Is the playwright stealth plugin enough, or do you need something else?
The playwright stealth plugin is enough when the check you fail runs as JavaScript inside the page, and not enough when it fails anywhere else. The PyPI page puts it bluntly: “Don’t expect this to bypass anything but the simplest of bot detection methods.”
| Situation | What works at that layer | What it leaves unfixed |
|---|---|---|
| Simple JavaScript checks | playwright-stealth | TLS handshake, behavior, IP |
| Automation-driver artifacts | Patched driver or browser build (Patchright, Camoufox) | IP, behavior; check each README for scope |
| IP reputation or geography | Residential or your own proxy | Fingerprint, behavior |
| Long-lived named profiles run by a person | Desktop profile manager (Multilogin) | IP, behavior; Wayfern does not do this |
| Code-driven hosted browser | Wayfern, Browserbase | Behavior; Wayfern has no persistent profiles |
Run the bare-versus-patched test from the earlier section first. If only JavaScript signals differ, stay with the plugin and pin the version.
If the signals that differ are driver artifacts, read the Patchright and Camoufox READMEs before you swap. We don’t summarize them here, because each project’s own text is the only reliable statement of what it patches. If the block follows your address, no browser patch helps. Fix the exit with a residential or own proxy.
Wayfern, candidly. Wayfern’s own Chromium build applies the browser identity (fingerprint), and you drive it over CDP. As our Bright Data comparison puts it, “the identity is not bolted on afterwards,” so there are no injected-script artifacts to find. It doesn’t fix behavior or IP reputation.
The limits are plain. Wayfern is Chromium family only, so if you need Firefox or WebKit, it is not the answer. It has no customer-facing persistent profiles and no per-vector fingerprint switches. Our Multilogin comparison says it directly: “If hand-managing identities is the job, Multilogin is doing that job and Wayfern is not.” For a wider shortlist, see our comparison of anti-detect browsers and the Browserbase alternative page.
Best for:
- playwright-stealth: scripts that fail simple JavaScript checks.
- Patched builds: driver artifacts, after you read their READMEs.
- Proxy: failures tied to IP or location.
- Profile manager: a person who maintains named identities.
- Hosted browser: code that needs a fresh Chromium session without running your own pool.
To see the CDP connection, read how Playwright connects to a hosted Wayfern browser.
Is using a stealth plugin for scraping allowed?
A stealth plugin is a tool, and the rules for using it come from the target site’s terms and the law, not from the plugin.
Before you collect anything, read the site’s terms of service and its robots.txt. RFC 9309 defines the Robots Exclusion Protocol as a way for service owners “to control how content served by their services may be accessed, if at all, by automatic clients known as crawlers.” It also says the protocol “is not a substitute for valid content security measures.”
A patched browser doesn’t make a collection lawful. Public-data collection, testing, research and monitoring are legitimate uses. Credential abuse, fraud and ignoring a site’s terms are out of scope. This isn’t legal advice, so ask a lawyer when the stakes are real.
Frequently asked questions
Why does stealth_async raise an AttributeError in playwright-stealth 2.x?
Because playwright-stealth 2.x replaced the v1 functions. Tutorials that call stealth_async(page) or stealth_sync(page) target 1.x, and 2.x exposes a Stealth class instead. Use Stealth().use_async(...) or apply_stealth_async(context), or pin a 1.x release, such as playwright-stealth==1.0.6 from PyPI’s release history. PyPI warns that the latest major version includes breaking changes.
Does the playwright stealth plugin work in Firefox and WebKit?
Most pages don’t document it, so test it yourself. The AtuboDad v1 README loops over p.chromium, p.firefox and p.webkit, but it publishes no results. Run the bare-versus-patched matrix from the testing section on each engine, and record the versions you pinned.
Can playwright-stealth hide my TLS fingerprint?
No, because the TLS handshake happens before any page JavaScript runs. RFC 8446 requires a client to send the ClientHello as its first TLS message, and a script patch can’t change a message that has already left. Page-level patches only run afterward.
Is playwright-stealth the same as puppeteer-extra-plugin-stealth?
No, but they’re related. puppeteer-extra-plugin-stealth is the Node original. AtuboDad’s repo says it was transplanted from it, and the PyPI package describes itself as a fork of that port. The result is a Python lineage with its own API.
What to do next, in order
Today: Pin playwright-stealth==2.0.3, the version PyPI lists as current. Then replace every stealth_async(page) and stealth_sync(page) call with the v2 Stealth API.
This week: Run the bare versus stealth test on your own target pages. Fill in the matrix, one row per browser and plugin version.
Before you scale: Read the failing layer. A leak visible to page JavaScript points to patches. A TLS, IP or reputation block points to a proxy. A check that fails after both points to a hosted browser.
The playwright stealth plugin is a starting point, not a finish line. If your matrix says hosted browser, connect Playwright to Wayfern’s anti-detect Chromium with chromium.connectOverCDP and a link token. Follow the /docs quickstart, then check /pricing.
Plans start at $19 a month. Wayfern is Chromium only, so it won’t help if you need Firefox or WebKit.
Sources
Playwright Stealth: Does It Still Work for Scraping?… · playwright-stealth · PyPI · GitHub - AtuboDad/playwright_stealth: playwright stealth · Page | Playwright Python · playwright-stealth · PyPI release history · BrowserContext | Playwright Python · RFC 8446: The Transport Layer Security (TLS) Protocol… · RFC 9309: Robots Exclusion Protocol