Every "best stealth browser 2026" list I can find has the same problem. The scores come from nowhere, half the recommended tools have not shipped a commit in over a year, and nobody publishes the code they ran. So I installed all of them and ran the tests myself.
Seven automation tools, six public fingerprinting pages, headless and headed, three repetitions each. That is 42 runs, plus a human baseline from my own browser so the numbers mean something. Every result below came off my machine, and the harness is at the end so you can rerun it.
The short version: headed or headless matters more than which tool you pick, four tools are indistinguishable from a real browser when headed, and every Chromium-based tool leaks in headless mode - including the three that look clean, which turn out to be leaking something subtler than the one they fixed.
The Quick Answer
| Tool | Headed | Headless | Use it when |
|---|---|---|---|
| Camoufox | Clean | Clean | You need headless and cannot run a display |
| SeleniumBase (CDP Mode) | Clean | Leaks Client Hints | You want Python + a mature test framework |
| Botasaurus | Clean | Leaks Client Hints | You want a scraping framework, not just a driver |
| Zendriver | Clean | Leaks Client Hints | You liked nodriver and want it maintained |
| Nodriver | Clean | Leaks HeadlessChrome |
Legacy projects. See the dead-code bug below |
| Patchright | Near-clean | Leaks HeadlessChrome |
You have existing Playwright code |
| Playwright (control) | Detected | Detected | Testing your own site, not scraping |
The Contenders, and the Ones That Are Dead
Half the tools recommended in search results are abandoned. I checked every repository's last push on the day I ran these tests:
| Project | Approach | Last push | Status |
|---|---|---|---|
| SeleniumBase | Patched chromedriver (UC Mode) and a pure CDP driver (CDP Mode) | 2026-09-17 | Active |
| Camoufox | Firefox forked and patched at the C++ level | 2026-09-14 | Active |
| Patchright | Patched Playwright, drop-in replacement | 2026-09-13 | Active |
| Zendriver | Community fork of nodriver | 2026-08-16 | Active |
| Botasaurus | Scraping framework with its own stealth driver | 2026-07-26 | Active |
| Nodriver | Raw CDP to unmodified Chrome | 2026-05-13 | Slowing |
| undetected-chromedriver | Patched chromedriver | 2025-07-05 | Stale |
| rebrowser-patches | Puppeteer/Playwright runtime patches | 2025-05-09 | Stale |
| puppeteer-extra-stealth | JS evasion plugins | 2024-07-18 | Dead |
If an article published this year recommends puppeteer-extra-stealth, it is recommending a library that has not been touched in over two years while Chrome shipped roughly twenty major versions. Detection moved. It did not.
The three live approaches are worth understanding, because they explain the results.
Patchright patches Playwright's own leaks. The big one is Runtime.enable: standard CDP automation enables the Runtime domain to evaluate JavaScript, and that is directly observable from the page. Patchright runs JS in isolated execution contexts instead, disables the Console API entirely, and strips the giveaway launch flags (--enable-automation out, --disable-blink-features=AutomationControlled in).
Camoufox takes the hardest path: it forks Firefox and injects fingerprint data at the C++ implementation level, so there is no JavaScript patch for a detection script to find. Properties you do not set are filled in from BrowserForge, which samples real-world device distributions.
Nodriver, Zendriver, Botasaurus and SeleniumBase CDP Mode (the HTTP-client side of this stack is covered in Web Scraping Tools in 2026) all share one idea: never attach WebDriver at all. They speak raw CDP to a normal Chrome. There is no navigator.webdriver to hide because nothing set it.
Methodology
Windows 11, i5-13420H, 24 GB RAM, residential connection. Chrome 152.0.7977.84. Package versions: camoufox 0.5.6, patchright 1.62.3, playwright 1.62.0, seleniumbase 4.54.8, botasaurus-driver 4.0.101, zendriver 0.16.0, nodriver 0.50.3.
Each tool ran in its documented stealth configuration, in a fresh temporary profile, in its own subprocess, three times per mode. The pages:
| Page | What it reports |
|---|---|
| bot.sannysoft.com | 31 individual checks, pass/fail |
| CreepJS | Percentages for "like headless", "headless", "stealth" |
| deviceandbrowserinfo.com/are_you_a_bot | A bot/human verdict plus the specific signals that fired |
| browserscan.net/bot-detection | Overall verdict |
| fingerprint-scan.com | A bot risk score from 0 to 100 |
| tls.peet.ws | JA4 TLS fingerprint and the wire headers |
The baseline matters more than any score. A number like "19% like headless" is meaningless without knowing what a real browser scores. On my normal Chrome, driven by hand: CreepJS 19% like headless, 0% headless, 0% stealth, fingerprint-scan 0/100, deviceandbrowserinfo human. That is the target. Nothing can beat it, and matching it is the goal.
What I left out, and why. I did not test against Cloudflare Turnstile, and neither should any benchmark using the pages people usually reach for. The two "Turnstile test" pages that show up in every guide both use Cloudflare's dummy sitekeys: SeleniumBase's demo uses 3x00000000000000000000FF, which forces a challenge for everyone, and Cloudflare's own demo uses 1x00000000000000000000AA, which passes for everyone. Neither measures your browser. Testing against a real Turnstile key means testing against somebody's live protection, which is a different activity from benchmarking. So: fingerprinting pages only.
I also did not publish raw page output. These pages report your public IP through WebRTC and your timezone. Only scores left the machine.
The Control: What Vanilla Playwright Leaks
Before judging stealth tools, it helps to see what they are fixing. Vanilla Playwright driving real Chrome, headless:
- sannysoft: 4 failures -
User Agent (Old),WebDriver (New),HEADCHR_UA,CHR_MEMORY - CreepJS: 38% like headless, 100% headless, 0% stealth
- deviceandbrowserinfo: bot, with six signals firing including
hasWebdriverTrue,isAutomatedWithCDPandisAutomatedWithCDPInWebWorker - BrowserScan: Robot
- fingerprint-scan: 100/100
Headed, it still fails. navigator.webdriver is still true, CDP is still detectable, and both isAutomatedWithCDP and isAutomatedWithCDPInWebWorker still fire. This is the floor everything else is measured against.
Results: Headed
All three repetitions agreed unless noted.
| Tool | sannysoft fails | CreepJS (like/headless/stealth) | are_you_a_bot | BrowserScan | fingerprint-scan |
|---|---|---|---|---|---|
| Real Chrome (baseline) | n/a | 19 / 0 / 0 | human | n/a | 0 |
| Playwright | 1 | 31 / 33 / 0 | bot (5 signals) | Robot | 100 |
| Patchright | 0 | 25 / 0 / 0 | human | Normal | 15 |
| Camoufox | 1 (false positive) | 6 / 0 / 0 | human | Normal | 5 |
| SeleniumBase | 0 | 19 / 0 / 0 | human | Normal | 15, 15, 0 |
| Botasaurus | 0 | 19 / 0 / 0 | human | Normal | 0 |
| Zendriver | 0 | 19 / 0 / 0 | human | Normal | 15, 0, 15 |
| Nodriver | 0 | 19 / 0 / 0 | human | Normal | 0 |
Four tools hit 19/0/0, which is my real browser's score exactly. Botasaurus and Nodriver also scored 0 on fingerprint-scan, matching the baseline on every metric I measured.
Two notes on reading this table. Camoufox's single sannysoft failure is Chrome (New), which tests for window.chrome. Firefox does not have window.chrome, and a real Firefox fails it too, so that is a Chromium-specific check, not a detection. And the fingerprint-scan column shows why single-run benchmarks mislead: SeleniumBase and Zendriver scored 15 in some runs and 0 in others with no config change. Only Patchright scored 15 consistently. I could not pin down the cause, so I am reporting the variance rather than inventing an explanation.
Results: Headless
This is where they separate.
| Tool | sannysoft fails | CreepJS | are_you_a_bot | BrowserScan | fingerprint-scan |
|---|---|---|---|---|---|
| Playwright | 4 | 38 / 100 / 0 | bot (6 signals) | Robot | 100 |
| Patchright | 3 | 31 / 67 / 0 | bot (hasBotUserAgent) |
Robot | 100 |
| Nodriver | 3 | 25 / 67 / 0 | bot (hasBotUserAgent) |
Robot | 100 |
| Camoufox | 1 (false positive) | 6 / 0 / 0 | human | Normal | 5 (one run: 20) |
| SeleniumBase | 0 | 31 / 33 / 0 | human | Normal | 100 |
| Botasaurus | 0 | 31 / 33 / 0 | human | Normal | 100 |
| Zendriver | 0 | 31 / 33 / 0 | human | Normal | 100 |
Camoufox is the only tool that passes headless. Everything else is caught by at least one page.
Patchright and Nodriver leak the obvious thing: HeadlessChrome sits right in the user agent string, so hasBotUserAgent fires and BrowserScan says Robot.
The interesting group is SeleniumBase, Botasaurus and Zendriver. They pass sannysoft with zero failures, pass deviceandbrowserinfo as human, pass BrowserScan, and still score 100/100 on fingerprint-scan and 33% headless on CreepJS. Something is giving them away that four other pages miss.
The Nodriver Bug: A Fix That Never Runs
Nodriver 0.50.3 contains the code to fix its own UA leak. In nodriver/core/tab.py there is a _prepare_headless() method that reads navigator.userAgent and re-sends it with "Headless" stripped out. It works. It is just never called.
pip install nodriver==0.50.3
grep -rn "_prepare_headless" "$(python -c 'import nodriver, os; print(os.path.dirname(nodriver.__file__))')" --include=*.py
# nodriver/core/tab.py:227: async def _prepare_headless(self):
# ...and that is the only hit. Defined, never invoked.Zendriver, the community fork, calls the same method from connection.py:560 whenever headless is set. That single line is the entire difference between "passes sannysoft" and "fails three checks", and it is why the fork exists: nodriver stopped taking contributions, and the bugs stayed.
The Leak Nobody Talks About: Client Hints
So why do SeleniumBase, Botasaurus and Zendriver still score 100 while looking clean?
All three fix the UA the same way, with a CDP Network.setUserAgentOverride call carrying only a user agent string. That call has a side effect: overriding the user agent without also supplying userAgentMetadata wipes Client Hints. I measured it directly on each tool:
HeadlessChrome in UA |
userAgentData.brands |
sec-ch-ua header |
|
|---|---|---|---|
| Real Chrome | no | 3 brands | present |
| Playwright / Patchright / Nodriver, headless | yes | 3 brands | present |
| SeleniumBase / Botasaurus / Zendriver, headless | no | empty | missing |
So there are two ways to fail, and every Chromium tool picks one. Either you announce yourself as headless in the UA, or you claim to be Chrome 152 while sending no sec-ch-ua header at all - which no real Chrome does, and which a server can check without running a line of JavaScript. The three "clean" tools swapped a loud leak for a quiet one.
Fixing it means passing the metadata along with the override. Do not hardcode the brand list, because the GREASE brand (Not?A_Brand) and the version numbers change between Chrome releases. Read the real values from the browser you just launched:
import json
import zendriver as zd
async def fix_headless_identity(tab):
"""Strip 'HeadlessChrome' from the UA without losing Client Hints."""
# Tools that already set a metadata-less override wiped the hints.
# An empty user agent clears that override so we can read the real values.
await tab.send(zd.cdp.network.set_user_agent_override(user_agent=""))
await tab.reload()
hints = await tab.evaluate(
"""navigator.userAgentData.getHighEntropyValues(
['architecture', 'bitness', 'model', 'platformVersion', 'fullVersionList'])
.then(h => JSON.stringify({...h,
platform: navigator.userAgentData.platform,
mobile: navigator.userAgentData.mobile,
ua: navigator.userAgent}))""",
await_promise=True,
)
h = json.loads(hints)
brand = zd.cdp.emulation.UserAgentBrandVersion
metadata = zd.cdp.emulation.UserAgentMetadata(
platform=h["platform"], platform_version=h["platformVersion"],
architecture=h["architecture"], model=h["model"],
mobile=h["mobile"], bitness=h["bitness"],
brands=[brand(brand=b["brand"], version=b["version"].split(".")[0]) for b in h["fullVersionList"]],
full_version_list=[brand(brand=b["brand"], version=b["version"]) for b in h["fullVersionList"]],
)
await tab.send(zd.cdp.network.set_user_agent_override(
user_agent=h["ua"].replace("HeadlessChrome", "Chrome"),
user_agent_metadata=metadata,
))Note the secure-context requirement: navigator.userAgentData does not exist on about:blank, so run this after navigating to an HTTPS page.
That restores the headers. It moves CreepJS from 31% to 25% like-headless. And fingerprint-scan still says 100.
What Headless Actually Leaks: The Screen
Two things were still wrong, and neither is about automation.
CreepJS's "headless" section has exactly three checks: webDriverIsOn, hasHeadlessUA, and hasHeadlessWorkerUA. A score of 33% means one of three fired. The page UA was fixed, navigator.webdriver was clean, so it was the worker. CreepJS reads its worker values from a service worker, and a service worker is a separate CDP target that a page-level UA override never reaches. It was still reporting HeadlessChrome.
The second signal was the display. Comparing the same tool headed and headless:
| Signal | Headed | Headless |
|---|---|---|
screen |
1536x864 | 800x600 |
screen.availHeight == screen.height |
false | true |
devicePixelRatio |
1.25 | 1 |
Headless Chrome reports an 800x600 screen with no taskbar and no display scaling. That combination does not occur on a real desktop, and it costs nothing to check.
Reaching for --window-size makes it worse: the viewport becomes 1920x1080 while screen stays 800x600, so the page is now larger than the screen containing it, which is impossible.
Both problems have the same fix, and it is a launch flag rather than a CDP call. --user-agent applies browser-wide, so it reaches service workers and keeps Client Hints intact, which the CDP override does not. --screen-info sets the reported screen, work area and pixel ratio:
import zendriver as zd
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
"(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" # your Chrome's real UA, minus "Headless"
browser = await zd.start(headless=True, browser_args=[
f"--user-agent={UA}",
"--screen-info={0,0 1920x1080 devicePixelRatio=1.25 workAreaBottom=48}",
"--window-size=1536,824",
])
tab = await browser.get("https://example.com")
# zendriver applies its own metadata-less override on connect - clear it so the flag governs
await tab.send(zd.cdp.network.set_user_agent_override(user_agent=""))Results for headless Zendriver with those two flags, four runs, identical every time:
| Stock headless | With both flags | Real Chrome | |
|---|---|---|---|
| CreepJS | 31 / 33 / 0 | 19 / 0 / 0 | 19 / 0 / 0 |
| fingerprint-scan | 100 | 15 | 0 |
| are_you_a_bot | human | human | human |
| sannysoft | 0 fails | 0 fails | n/a |
sec-ch-ua |
missing | present | present |
| Service worker UA | HeadlessChrome | clean | clean |
Headless now matches my real browser on CreepJS exactly, and scores the same 15 on fingerprint-scan that SeleniumBase and Zendriver get when running headed. The remaining gap between 15 and 0 is something I have not identified.
The useful part is not the flags. It is that no tool in this test does this for you. Every one of them fixes navigator.webdriver and stops there, while the browser announces an 800x600 screen.
Performance
Median of three runs, full six-page sequence. Memory is peak resident set across the whole browser process tree, which is dominated by how many renderer processes Chrome spawns, so read it as a rough band rather than a precise figure.
| Tool | Launch, headless | Launch, headed | Peak RSS |
|---|---|---|---|
| Botasaurus | 1.5s | 1.8s | ~1.4 GB |
| Zendriver | 1.4s | 1.6s | ~1.4 GB |
| Nodriver | 1.4s | 1.4s | ~1.4 GB |
| SeleniumBase | 3.3s | 3.4s | ~1.4 GB |
| Playwright | 4.0s | 1.3s | ~1.2 GB |
| Patchright | 4.0s | 1.3s | ~1.6 GB |
| Camoufox | 10.0s | 10.1s | ~1.0 GB |
Camoufox is 7x slower to start than the CDP drivers. For a long-running session that is irrelevant. For short-lived containers that launch a browser per job, it dominates your runtime.
One honesty note on the Playwright and Patchright headless figures: individual runs were 1.1s, 5.0s and 4.0s. The median is 4.0s but the spread is real, and I would not read anything into the difference between them and the 1.4s group.
The Harness
Every tool exposes a different API, so the harness reduces each to two functions - navigate, and evaluate JavaScript - and runs identical extractors against all of them:
from contextlib import contextmanager
import tempfile, asyncio, shutil
@contextmanager
def patchright_session(headless):
import patchright.sync_api as m
with m.sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
tempfile.mkdtemp(), channel="chrome", headless=headless, no_viewport=True)
page = ctx.pages[0]
try:
yield (lambda url, wait: (page.goto(url, timeout=60000), page.wait_for_timeout(wait)),
lambda expr: page.evaluate(expr))
finally:
ctx.close()
@contextmanager
def zendriver_session(headless):
import zendriver as zd
loop = asyncio.new_event_loop()
browser = loop.run_until_complete(zd.start(headless=headless))
state = {}
async def goto(url, wait):
state["tab"] = await browser.get(url)
await state["tab"].sleep(wait / 1000)
try:
yield (lambda url, wait: loop.run_until_complete(goto(url, wait)),
lambda expr: loop.run_until_complete(state["tab"].evaluate(expr)))
finally:
loop.run_until_complete(browser.stop())
SANNYSOFT = """JSON.stringify({
passed: document.querySelectorAll('td.passed').length,
failed: [...document.querySelectorAll('td.failed')]
.map(td => td.parentElement.cells[0].innerText.trim())
})"""
with zendriver_session(headless=True) as (goto, js):
goto("https://bot.sannysoft.com/", 6000)
print(js(SANNYSOFT))Two things that cost me time and will cost you time too. Run each tool in a separate subprocess: a crash or hang in one otherwise takes the whole matrix with it. And poll for verdicts instead of sleeping a fixed interval. deviceandbrowserinfo took over 45 seconds to render its result on some runs, and my first pass recorded those as empty. I nearly wrote them up as detections.
What Passing These Pages Does Not Mean
These are fingerprinting pages. They are not Cloudflare, DataDome or Akamai. Specifically, none of this covers:
- IP reputation. Every run came from a residential connection. A datacenter IP changes the outcome before any JavaScript runs, and it is often the entire decision.
- Behavior. Nothing here moves a mouse or types. Navigation that is too direct, form fills with no input events, and sessions that never idle are all signals these pages do not measure.
- TLS consistency. Every Chromium tool produced an identical JA4 (
t13d1517h2_8daaf6152771_cb7bf5808d99) because they all drive the same installed Chrome. Camoufox produced a Firefox JA4 matching its Firefox UA, which is correct. (I covered how these protocol-level fingerprints work in Fingerprinting Beyond JA3.) But Camoufox rotates its spoofed OS, and in one headless run it presented a macOS user agent while running on Windows. That run also scored its worst on fingerprint-scan, 20 instead of 5. That is a single observation, not a proven cause, but an OS-level mismatch is exactly the sort of thing a fingerprinting service cross-checks against fonts and WebGL. - Server environments. Every number here comes from a laptop with a real GPU and a real display. On a VPS the picture is very different, and that is the subject of the follow-up post.
Key Takeaways
- Headed versus headless matters more than tool choice. Four different tools match a real browser exactly when headed.
- Camoufox is the only tool that passes headless out of the box, and it costs you a ~10 second launch.
- Every Chromium tool leaks in headless mode: either
HeadlessChromein the UA, or a metadata-less UA override that silently removessec-ch-ua. Check the wire headers, not justnavigator.userAgent. - Nodriver 0.50.3 ships the fix for its own UA leak and never calls it. Zendriver, the fork, calls it. That is the whole difference.
- Headless Chrome reports an 800x600 screen with no taskbar and no display scaling.
--screen-infofixes it. No tool does this for you. - Prefer
--user-agentover a CDP override: it reaches service workers and preserves Client Hints. - Stop using puppeteer-extra-stealth, undetected-chromedriver and rebrowser-patches. They have not shipped in over a year.
- Always establish a human baseline. "19% like headless" sounds bad until you see a real browser score it.
Testing was done against public fingerprinting test pages that exist for this purpose, using my own machine and connection. Nothing here targets a production site or a live protection system. Check that you have authorization before testing any system you do not own.