DEV Community

Daniel Carlsson
Daniel Carlsson

Posted on Originally published at nonecap.com

Playwright stealth in 2026: setup in Python and Node, and where it stops

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.userAgentData and the Sec-CH-UA header.
  • 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, so srcdoc frames 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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

Gotcha: older tutorials call stealth_sync(page). That function was removed in 2.0, and on 2.0.3 importing it raises an ImportError. Use apply_stealth_sync instead.

Node setup

npm install playwright playwright-extra puppeteer-extra-plugin-stealth
npx playwright install chromium
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)