Playwright stealth: setup and limits.
playwright-stealth hides automation tells such as
navigator.webdriver. How to set it up in Python or Node, what
its docs say it won’t do, and what to do about hCaptcha.
What playwright-stealth is
Playwright drives a real browser, but an automated one reports itself in
ways a page script can read: navigator.webdriver is
true, headless Chrome puts HeadlessChrome in its
user agent, and several browser objects look different from an ordinary
Chrome. A stealth package runs patches in every page before the site’s own
scripts load, so those values read like a normal browser’s. There are two
packages, one per language:
| Package | For | Latest version | What it is |
|---|---|---|---|
playwright-stealth (PyPI) | Python | 2.0.3, released 4 April 2026 | A fork of AtuboDad’s Python port of the Node stealth plugin, maintained by Mattwmaster58. MIT licence, Python 3.9+ |
playwright-extra + puppeteer-extra-plugin-stealth (npm) | Node | 4.3.6 and 2.11.2, both published 1 March 2023 | A plugin wrapper around Playwright, plus the stealth plugin from the puppeteer-extra project. MIT licence |
Both trace back to the same project.
puppeteer-extra-plugin-stealth
was written for Puppeteer, and
playwright-extra
lets Playwright load it. The Python package on
PyPI
started as a port of that plugin, and the
Mattwmaster58 fork
is what pip install playwright-stealth
installs today.
What it patches
Each patch is called an evasion, and each can be turned on or off. The
Python package’s options and the plugin’s
evasions folder list the same core set:
- Automation flag:
navigator.webdriver. - Identity strings: the user agent,
navigator.platform,navigator.vendor,navigator.languages,navigator.userAgentDataand theSec-CH-UAheader. - Browser objects headless Chrome lacks or fakes badly:
window.chrome(app,csi,loadTimes,runtime),navigator.plugins,navigator.permissionsand media codecs. - Hardware and rendering: the WebGL vendor and renderer strings and
navigator.hardwareConcurrency. - Frames:
iframe.contentWindow, patched so frames built withsrcdocdo not give automation away.
We ran both quick starts below on 2 November 2026 with Playwright 1.64.1
and headless Chromium. Without stealth, navigator.webdriver
read true and the user agent said HeadlessChrome;
with either package it read false and the user agent said
Chrome.
Install and use it in Python
pip install playwright-stealth
playwright install chromium The recommended way in version 2 is to wrap Playwright’s context manager, so every browser and page you open gets the patches:
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth
# Every browser, context and page opened inside this block gets the patches.
with Stealth().use_sync(sync_playwright()) as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://your-site.example/")
print(page.evaluate("navigator.webdriver")) # False (True without stealth)
browser.close()
The async API is the same with use_async and
async_playwright(). To patch a single context or page instead,
or to change the defaults, build a Stealth object with
keyword arguments:
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth, ALL_EVASIONS_DISABLED_KWARGS
stealth = Stealth(navigator_languages_override=("fr-FR", "fr"))
with sync_playwright() as p:
browser = p.chromium.launch()
context = browser.new_context()
stealth.apply_stealth_sync(context) # only this context is patched
page = context.new_page()
# Only the navigator.webdriver patch, nothing else:
webdriver_only = Stealth(**{**ALL_EVASIONS_DISABLED_KWARGS, "navigator_webdriver": True})
Older tutorials call stealth_sync(page). That function went
away in 2.0, and on 2.0.3 importing it raises an ImportError;
apply_stealth_sync is its
replacement.
Install and use it in Node
npm install playwright playwright-extra puppeteer-extra-plugin-stealth
npx playwright install chromium playwright-extra is a drop-in replacement for the
playwright import; you add the plugin once and use Playwright
as normal. The example uses top-level await, so save it as an
.mjs file:
import { chromium } from 'playwright-extra'
import StealthPlugin from 'puppeteer-extra-plugin-stealth'
chromium.use(StealthPlugin())
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
await page.goto('https://your-site.example/')
console.log(await page.evaluate(() => navigator.webdriver)) // false
await browser.close() StealthPlugin takes an enabledEvasions set to
choose which patches run, and single evasions such as
stealth/evasions/webgl.vendor
take their own options through
chromium.plugins.setDependencyDefaults;
the
playwright-extra README
shows how.
What its own docs say it does not do
Both projects are frank about their limits. In their own words, as read on 2 November 2026:
- Simple checks only. The Python README: “Don’t expect this to bypass anything but the simplest of bot detection methods. Consider this a proof-of-concept starting point.”
- Not undetectable. The Node plugin’s README: “It’s probably impossible to prevent all ways to detect headless chromium, but it should be possible to make it so difficult that it becomes cost-prohibitive or triggers too many false-positives to be feasible.”
- Chromium first. playwright-extra says the stealth plugin “is optimized for chromium based browsers currently”.
- No persistent contexts yet. The Python package’s to-do list opens with “make this work with playwright.launch_persistent_context”.
- CAPTCHAs are a separate plugin. The playwright-extra README lists CAPTCHA solving under a different plugin, which hands the challenge to an outside solving service. Neither stealth package’s README mentions hCaptcha.
The Node packages have had no npm release since March 2023. The Python
fork is the active one: its 2.0.3 release in April 2026 added the
navigator.userAgentData patch.
Patchright takes a different
route: it is a patched copy of Playwright itself, so you change the import
instead of adding a plugin.
Does playwright-stealth get past hCaptcha?
A stealth patch changes what your browser says about itself. It can make a visit look less like automation, and that is one of several things that decide whether a site’s hCaptcha shows a challenge at all; why am I getting hCaptcha covers the others, starting with the reputation of your IP address, which no browser patch changes.
Once a challenge does appear, stealth has nothing more to offer. The site
accepts the form only with a valid token in the
h-captcha-response field, a
P1_ string its server checks with hCaptcha before it lets the
request through. Invisible hCaptcha needs that token too, even when no
puzzle is shown. A browser patch does not produce one.
Where NoneCap fits
NoneCap is an hCaptcha solving API. Keep stealth for the rest of your
Playwright run, and when a page has hCaptcha, send its
sitekey and url to NoneCap, get a
P1_ token back, put it in the form and submit. Failed,
cancelled and expired solves are never charged.
import os
import time
import requests
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth
API = "https://api.nonecap.com/v1/solves"
HEADERS = {"Authorization": f"Bearer {os.environ['NONECAP_KEY']}"}
def solve_hcaptcha(sitekey: str, url: str) -> str:
r = requests.post(
API,
headers=HEADERS,
params={"wait": 90},
json={"type": "hcaptcha", "sitekey": sitekey, "url": url},
timeout=120,
)
r.raise_for_status()
data = r.json()
while data["status"] in ("pending", "solving"):
time.sleep(2)
data = requests.get(f"{API}/{data['id']}", headers=HEADERS, timeout=30).json()
if data["status"] != "solved":
raise RuntimeError(f"solve {data['status']}: {data.get('error')}")
return data["token"]
with Stealth().use_sync(sync_playwright()) as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://your-site.example/signup")
sitekey = page.locator("[data-sitekey]").first.get_attribute("data-sitekey")
token = solve_hcaptcha(sitekey, page.url)
page.evaluate(
"""(token) => {
for (const name of ['h-captcha-response', 'g-recaptcha-response']) {
const el = document.querySelector(`textarea[name="${name}"]`);
if (el) el.value = token;
}
}""",
token,
)
page.click("button[type=submit]")
browser.close()
The Playwright guide has the Node
version, invisible and enterprise (rqdata) sitekeys, and how
to tell hCaptcha from reCAPTCHA and Turnstile in the DOM. Every field and
status is in the API reference. NoneCap
solves hCaptcha only; it does not help with reCAPTCHA,
Turnstile or other CAPTCHAs.
Last updated November 2026.
Frequently asked
What is playwright-stealth?
playwright-stealth is a Python package that patches a Playwright browser so it reports fewer automation markers, such as navigator.webdriver. In Node, “Playwright stealth” usually means playwright-extra with puppeteer-extra-plugin-stealth, the plugin the Python package was ported from.How do I install playwright-stealth?
pip install playwright-stealth, then wrap your Playwright context manager: with Stealth().use_sync(sync_playwright()) as p:. In Node, npm install playwright playwright-extra puppeteer-extra-plugin-stealth, import chromium from playwright-extra and call chromium.use(StealthPlugin()).Why do I get “cannot import name stealth_sync”?
stealth_sync(page) and stealth_async(page). In 2.0.3 the package exports only Stealth and ALL_EVASIONS_DISABLED_KWARGS. Use Stealth().use_sync(...) for everything, or Stealth().apply_stealth_sync(page) (and the _async versions) for one page or context.Does playwright-stealth work with launch_persistent_context?
Does playwright-stealth work in Firefox and WebKit?
Does playwright-stealth bypass CAPTCHA?
h-captcha-response, and the patch does not provide one. NoneCap returns that token for hCaptcha.