Stealth
zendriver-rs ships with three stealth profiles selecting different tradeoffs between launch overhead, detectability, and CSP compatibility. Pick the profile that matches your target site's detection layer; tweak the fingerprint with builder methods when you need to pin a specific identity.
None of these profiles is a guarantee of invisibility — no automation stack has one against a determined, adaptively-defended site. What they provide is a coherent, plausible browser identity plus explicit controls over the specific tells that mainstream fingerprint checks probe for.
The three profiles
| Profile | Launch flags | UA scrub | Emulation overrides | JS bootstrap | Bypass CSP | Use case |
|---|---|---|---|---|---|---|
off() | none | no | no | none | no | Reproducing issues in vanilla Chrome. |
native() | yes | yes | yes | none | no | Most sites. Default recommendation. |
spoofed() | yes | yes | yes | Navigator JS | yes (on) | Sites with active fingerprint detection. |
StealthProfile::off()
#![allow(unused)] fn main() { use zendriver::{Browser, StealthProfile}; async fn ex() -> zendriver::Result<()> { let browser = Browser::builder() .stealth(StealthProfile::off()) .launch() .await?; Ok(()) } }
No launch flags. No UA scrub. No CDP overrides. Page.setBypassCSP is
not called. This is what you get from a stock chromiumoxide launch.
Use this when you're debugging whether a bug reproduces in a vanilla
Chrome — if it does, the cause is unrelated to zendriver's stealth
machinery.
StealthProfile::native()
#![allow(unused)] fn main() { use zendriver::{Browser, StealthProfile}; async fn ex() -> zendriver::Result<()> { let browser = Browser::builder() .stealth(StealthProfile::native()) .launch() .await?; Ok(()) } }
The default recommendation. Patches the layer that protocol-level fingerprinters see, without touching JS object prototypes:
- Launch flags —
--disable-blink-features=AutomationControlled,--disable-features=IsolateOrigins,site-per-process(toggleable), and a curated list of flags that turn off the "this browser is controlled by automation" infobar plus various leaks. - UA scrub — strips the
HeadlessChromesegment from the User-Agent string and the Sec-CH-UA brand list. - Emulation overrides —
Emulation.setUserAgentOverride/Emulation.setHardwareConcurrencyOverride/Emulation.setDeviceMetricsOverrideset a coherent identity.
Safe against Function.prototype.toString detection because it patches
nothing at the JS level — there's no [native code] mismatch to detect.
Passes most consumer site detectors. Doesn't pass sannysoft's deeper
Navigator-prototype checks.
StealthProfile::spoofed()
#![allow(unused)] fn main() { use zendriver::{Browser, StealthProfile}; async fn ex() -> zendriver::Result<()> { let browser = Browser::builder() .stealth(StealthProfile::spoofed()) .launch() .await?; Ok(()) } }
native() plus Navigator-prototype JS patches injected via
Page.addScriptToEvaluateOnNewDocument. Restores or overrides:
navigator.webdriver(deletes it so'webdriver' in navigatorisfalse).navigator.permissions.query({ name: "notifications" })(returns"prompt"instead of the headless-Chrome"denied").navigator.plugins+navigator.mimeTypes(returns plausible-length arrays).navigator.chrome(installs the runtime object headless Chrome doesn't ship).- WebGL vendor / renderer, plus every other readable WebGL value,
resolved from one capability tier (defaults to a captured Apple Metal
device:
"Google Inc. (Apple)"/"ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)"). The WebGPU adapter is derived from that same renderer string, so the two APIs never name different GPUs. - ChunkSplit + iframe-contentWindow guards so the patches survive cross-realm escape attempts.
Toggles Page.setBypassCSP on by default so the bootstrap script
can install on pages with strict CSP headers. Pass
.bypass_csp(false) to opt out when you want to test against a real
CSP-restricted page.
Passes sannysoft, areyouheadless, and most active detectors. Pays a small per-navigation cost (the JS bootstrap runs on every new document).
Customizing the fingerprint
All three profiles return a builder that lets you override individual
fingerprint fields. The values are validated and clamped at resolve
time (e.g. memory_gb is clamped to a plausible W3C-rounded value;
cpu_count is clamped to 2..=32).
#![allow(unused)] fn main() { use zendriver::{Browser, StealthProfile}; use zendriver::stealth::Platform; async fn ex() -> zendriver::Result<()> { let profile = StealthProfile::spoofed() .memory_gb(8) // navigator.deviceMemory .cpu_count(8) // navigator.hardwareConcurrency .chrome_version(126) // Chrome major in UA + Sec-CH-UA .platform(Platform::Win32) // navigator.platform + OS in UA .locale("en-US") // navigator.language + Accept-Language + --lang .timezone("America/New_York"); let browser = Browser::builder() .stealth(profile) .launch() .await?; Ok(()) } }
locale alone is usually enough. It sets the --lang launch flag, the
JS-visible locale that navigator.language and Intl read, and an
Accept-Language derived from it (en-US gives en-US,en;q=0.9). Reach
for .languages([...]) when you need the header to advertise a longer or
differently-weighted list. Its first entry then becomes
navigator.language as well, because Chrome always reports that as
navigator.languages[0], and a locale sitting outside the advertised
list is a mismatch no real browser produces.
A Persona
carries the same timezone / locale / languages / screen fields,
and whichever ones it sets take precedence over the profile's. Anything
it leaves unset falls through to the values above, so a persona can pin
one axis without restating the rest.
You can also override the User-Agent string verbatim — useful when you need an exact UA that doesn't match the auto-composed one:
#![allow(unused)] fn main() { use zendriver::{Browser, StealthProfile}; async fn ex() -> zendriver::Result<()> { let profile = StealthProfile::native() .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..."); let browser = Browser::builder() .stealth(profile) .launch() .await?; Ok(()) } }
user_agent() skips the auto-composition step entirely — prefer
platform() + chrome_version() unless you need a bit-for-bit
specific UA.
Opting into real site isolation + real WebGL (native_isolation)
native() and spoofed() both disable Chrome's render-process site
isolation (--disable-features=IsolateOrigins,...,site-per-process),
and spoofed() additionally patches
WebGLRenderingContext.getParameter() /getSupportedExtensions() to
report a coherent GPU identity — every readable parameter resolved from
one capability tier, defaulting to an Apple Metal device — regardless of
the host's actual GPU. .native_isolation(true) opts a profile out of
both:
#![allow(unused)] fn main() { use zendriver::{Browser, StealthProfile}; async fn ex() -> zendriver::Result<()> { let profile = StealthProfile::spoofed().native_isolation(true); let browser = Browser::builder() .stealth(profile) .launch() .await?; Ok(()) } }
With this set:
- The launch flags omit
IsolateOrigins/site-per-processfrom--disable-features=...— Chrome runs with its normal render-process isolation boundary. (The unrelatedDisableLoadExtensionCommandLineSwitchfeature name stays disabled either way — it controls--load-extension, not isolation.) - For
spoofed(), the bootstrap script omits the WebGL vendor/renderer patch entirely —getParameter(UNMASKED_VENDOR_WEBGL)etc. return the host's real values instead of the spoofed default.
This is a trade-off, not a strict stealth improvement. The defaults
it opts out of exist as anti-detection measures: the WebGL patch in
particular is an anti-WAF coherence defense — some WAFs
(Imperva/Incapsula) cross-check the WebGL identity against the rest of
the fingerprint and flag a real, host-specific renderer string as a bot
tell when it doesn't match. Reach for native_isolation(true) when you
need the host's actual GPU behavior (WebGL-heavy rendering, screenshot
fidelity, visual regression testing) or want Chrome's stock
process-isolation security boundary, and evasion isn't the priority —
not because it's "more stealthy." It isn't; it removes a defense.
It's off by default on every profile, so existing native() /
spoofed() callers see no behavior change unless they opt in
explicitly.
WebGL and WebGPU stay coherent. When native_isolation drops the
WebGL patch, the WebGPU value adapter spoof (navigator.gpu,
driven by the
Persona
webgpu surface) is skipped along with it, so navigator.gpu reports
the real host adapter instead of one derived from a renderer the WebGL
patch no longer applies — no cross-API mismatch. The same holds for the
per-surface Strategy::Native on Webgl, which leaves the real renderer
in place for one persona rather than the whole profile. An explicit
Webgpu Block (hiding navigator.gpu) is renderer-neutral, so it is
still honored if you set it.
End-to-end example
This example launches with a custom UA, locale, and platform, then
reads them back via navigator.* to prove the overrides took:
//! Port of `zendriver/examples/set_user_agent.py`. //! //! Launch Chrome with a custom User-Agent, locale, and platform configured //! via [`StealthProfile`], then read them back via `navigator.*` to verify //! the override took effect. //! //! Python `tab.set_user_agent("...", accept_language="de", platform="Win32")` //! is a single-call helper that internally drives //! `Emulation.setUserAgentOverride`. zendriver-rs lifts that into the //! `StealthProfile` builder because the launcher already wires UA overrides //! through `StealthObserver`, so per-tab mutation has no equivalent yet. //! Setting the UA at launch matches the spec's "no JS-visible drift between //! launch and first frame" stealth property. //! //! `navigator.platform` reads as `Win32` once `Platform::Win32` is set. use zendriver::Browser; use zendriver::stealth::{Platform, StealthProfile}; #[tokio::main] #[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error async fn main() -> zendriver::Result<()> { tracing_subscriber::fmt::init(); let profile = StealthProfile::native() .user_agent("My user agent") .locale("de") .platform(Platform::Win32); let browser = Browser::builder() .headless(true) .stealth(profile) .launch() .await?; let tab = browser.main_tab(); tab.goto("https://example.com").await?; tab.wait_for_load().await?; let ua: String = tab.evaluate("navigator.userAgent").await?; let lang: String = tab.evaluate("navigator.language").await?; let platform: String = tab.evaluate("navigator.platform").await?; println!("{ua}"); // My user agent println!("{lang}"); // de println!("{platform}"); // Win32 browser.close().await?; Ok(()) }
Expected output:
My user agent
de
Win32
The override surface is intentionally narrow — anything that would let
you set incoherent values (e.g. Linux UA + navigator.platform = Win32)
goes through the Fingerprint resolver, which composes a coherent
identity.
When to use which
- Headless scraping of public sites — start with
native(). Most sites don't actively probe Navigator prototypes; the cheaper profile is plenty. - Sites with active bot detection (Cloudflare, PerimeterX,
DataDome, Akamai) — use
spoofed(). Pair with thecloudflareCargo feature when you specifically need Turnstile bypass — see Cloudflare. - Sites with strict CSP —
spoofed()defaults tobypass_csp = true, which is normally what you want. If you're testing a real CSP, override it with.bypass_csp(false)(and expect the JS bootstrap to fail to install). - Sites that read
Function.prototype.toStringlooking for[native code]mismatches —native()rather thanspoofed(), becausespoofed()'s prototype patches leave detectable fingerprints in the function-source readouts. zendriver's bootstrap papers over the obvious patches, but a determined adversary will still find drift.