Playwright launches a real browser, but an automated one gives itself away. navigator.webdriver reads true, headless Chrome writes HeadlessChrome into its user agent, and a handful of browser objects don't look like the ones an everyday Chrome has. A stealth package injects patches into every page before the site's scripts run, so those values come back looking normal.
This post covers both packages that go by the name, how to set each one up, the limits their own READMEs admit to, and what to do when the page in front of you has hCaptcha on it.
Two packages, one ancestor
| Package | Language | Latest version (read 2 Nov 2026) | What it is |
|---|---|---|---|
playwright-stealth (PyPI) |
Python | 2.0.3, released 4 April 2026 | Mattwmaster58's fork of AtuboDad's Python port of the Node plugin. MIT, 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 for Playwright plus the stealth plugin from puppeteer-extra. MIT |
Both come from the same place. puppeteer-extra-plugin-stealth was built for Puppeteer, and playwright-extra lets Playwright load it. The Python package began as a port of that plugin; pip install playwright-stealth now installs the Mattwmaster58 fork.
What gets patched
Each patch is an "evasion" you can switch on or off. The Python options and the Node plugin's evasions folder cover the same core set:
-
The automation flag:
navigator.webdriver. -
Identity strings: user agent,
navigator.platform,navigator.vendor,navigator.languages,navigator.userAgentDataand theSec-CH-UAheader. -
Objects headless Chrome lacks or fakes badly:
window.chrome(app,csi,loadTimes,runtime),navigator.plugins,navigator.permissions, media codecs. -
Hardware and rendering: WebGL vendor/renderer strings and
navigator.hardwareConcurrency. -
Frames:
iframe.contentWindow, sosrcdocframes don't leak automation.
We ran both quick starts below on 2 November 2026 with Playwright 1.64.1 and headless Chromium. Without stealth, navigator.webdriver was true and the user agent contained HeadlessChrome. With either package it was false and the user agent said Chrome.
Python setup
pip install playwright-stealth
playwright install chromium
Version 2 wants you to wrap Playwright's context manager, which patches every browser and page opened inside it:
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()
For async code, swap in use_async and async_playwright(). To patch only one context or page, or to change defaults, construct Stealth 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})
Gotcha: older tutorials call
stealth_sync(page). That function was removed in 2.0, and on 2.0.3 importing it raises anImportError. Useapply_stealth_syncinstead.
Node setup
npm install playwright playwright-extra puppeteer-extra-plugin-stealth
npx playwright install chromium
playwright-extra is a drop-in for the playwright import: register the plugin once, then use Playwright as you normally would. The snippet uses top-level await, so save it as .mjs:
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 accepts an enabledEvasions set to choose which patches run. Individual evasions such as stealth/evasions/webgl.vendor take options through chromium.plugins.setDependencyDefaults; the playwright-extra README has the details.
The limits, in the projects' own words
Both projects are upfront about what they don't do (quotes read 2 November 2026):
- Simple checks only. 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. Node plugin 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 first item on the Python package's to-do list is "make this work with playwright.launch_persistent_context".
- CAPTCHAs are someone else's job. The playwright-extra README puts CAPTCHA solving in a separate plugin that hands the challenge to an outside solving service. Neither stealth README mentions hCaptcha.
The Node packages haven't shipped an npm release since March 2023. The Python fork is the active one; 2.0.3 (April 2026) added the navigator.userAgentData patch.
So does it get you past hCaptcha?
A stealth patch changes what the browser reports about itself. That can make a visit look less automated, which is one of several inputs into whether a site's hCaptcha shows a challenge in the first place. Others, starting with your IP address's reputation, are out of reach of any browser patch.
Once a challenge is on screen, stealth has nothing left to give. The site only accepts the form with a valid token in the h-captcha-response field: a P1_ string the site's server checks with hCaptcha before letting the request through. Invisible hCaptcha needs that token too, even when no puzzle appears. No browser patch produces one.
Adding an hCaptcha token to a stealth run
NoneCap is a captcha solver service that focuses on hCaptcha. Keep stealth for the rest of the Playwright run; when a page has hCaptcha, send its sitekey and url, get a P1_ token back, drop it into the form and submit:
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()
Every field and status is in the API reference. On Node, the TypeScript SDK does the submit-and-poll loop for you in one solve() call. NoneCap handles hCaptcha only, not reCAPTCHA, Turnstile or other CAPTCHAs.
Automate where you're allowed to: your own sites, your own tests, and services whose terms permit it.
Originally published at nonecap.com, which has an FAQ covering the stealth_sync import error, persistent contexts and Firefox/WebKit support.
Top comments (0)