How to Configure Selenium Stealth to Avoid Detection
selenium-stealth rewrites the browser properties that a WebDriver session leaks by default, and it belongs to the wider toolkit described in Mastering Selenium for Dynamic Websites.
The short version: install the package, build your ChromeOptions with --disable-blink-features=AutomationControlled and the enable-automation switch excluded, create the driver, then call stealth() before the first driver.get(). Pass a coherent identity โ platform, vendor, webgl_vendor, renderer and languages that all describe the same plausible machine. That combination clears the flag-based checks that most heuristic detectors run first. It does nothing for your TLS fingerprint, your IP reputation, or your request timing, and it will silently rot as Chrome ships new majors, so the configuration is a starting point rather than a finished job.
Use this on sites you are entitled to scrape. Masking automation markers does not change what a site's terms of service permit, and it does not create a right of access where none exists โ read the terms, honour robots.txt, and keep your request rate low enough that a human operator would not notice you.
How the Patches Are Applied, and When
selenium-stealth is not one mechanism but three, applied at different moments in the browser's startup. Understanding the order matters, because a patch applied one step too late protects nothing.
The first layer is pure Chrome configuration. --disable-blink-features=AutomationControlled stops Blink from setting the navigator.webdriver property at all, and excludeSwitches: ["enable-automation"] removes the "Chrome is being controlled by automated test software" infobar and the switch that advertises it. Both must be set on the Options object before the browser process starts; there is no way to apply them afterwards.
The second layer is the Chrome DevTools Protocol. selenium-stealth calls Page.addScriptToEvaluateOnNewDocument under the hood, which registers a script that Chrome runs in every new document before any of the page's own JavaScript executes. This is what makes the patches survive navigation and, crucially, what makes them present when an anti-bot script runs in the document head. A patch applied with driver.execute_script() after driver.get() returns is already too late โ the detection script has read the original values and, in many implementations, has already posted them.
The third layer is the property rewriting itself: redefining navigator.webdriver, populating navigator.plugins and navigator.languages, and intercepting the WebGL getParameter calls that report the GPU vendor and renderer strings. Those last two are the ones people most often get wrong, and they are covered in more depth in Spoofing Canvas and WebGL Fingerprints.
What the Detector Actually Measures
It helps to think of a detection script as a consistency checker rather than a flag reader. Naive scripts test navigator.webdriver === true and stop; anything past that first tier compares values that should agree and scores you on the contradictions.
The common comparisons are worth listing precisely, because they tell you which stealth() arguments matter:
- Platform versus User-Agent. If
navigator.platformreportsWin32but the User-Agent string saysX11; Linux x86_64, the pair is impossible. Setplatform="Win32"only when the User-Agent also claims Windows. - WebGL vendor versus platform.
Intel Inc.withIntel Iris OpenGL Engineis a macOS pairing. On a claimed Windows machine,Google Inc. (Intel)with an ANGLE renderer string is the realistic combination. A mismatched pair is a stronger signal than an unpatched one, because real machines are never inconsistent. - Plugin list length. Headless Chrome historically reported zero plugins. A populated but implausible list โ five plugins with identical MIME types โ reads as synthetic.
navigator.languagesversusAccept-Language. These are set in two different places and are easy to leave disagreeing.- Screen and viewport geometry. A
0ร0outer window, or anavailHeightequal toscreen.heightwith no taskbar allowance, is characteristic of a headless launch. - Timing. How long between page load and the first click, and whether the interval between actions has any variance. Stealth patches do not touch this at all, which is one reason a well-configured explicit wait that waits for a real condition looks more natural than a fixed
time.sleep()loop.
None of these are secret. Public test pages such as bot.sannysoft.com render exactly these checks as a table, which is why the verification step below points at one.
What such a page cannot tell you is how a specific target weights them. Detection is almost always a score rather than a rule: a session accumulates suspicion from several weak signals and crosses a threshold, and the threshold is set per operator and often per endpoint โ a product listing may be permissive while the checkout path is not. That is why "I pass every row on the test page but I am still challenged" is a normal outcome rather than a contradiction, and why chasing individual rows past the obvious ones has diminishing returns compared with fixing the connection underneath.
A Complete, Runnable Configuration
The script below builds the options, creates the driver, applies the patches in the correct order, and reports the values a detector would read. Selenium 4.6 and later ship Selenium Manager, so no separate driver download is needed on a normal desktop or CI image.
pip install "selenium>=4.20" selenium-stealth
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium_stealth import stealth
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
def build_driver(headless: bool = True) -> webdriver.Chrome:
"""Create a Chrome driver with the automation markers removed."""
options = Options()
if headless:
options.add_argument("--headless=new")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("--window-size=1920,1080")
options.add_argument(f"--user-agent={USER_AGENT}")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
driver = webdriver.Chrome(options=options)
# Must run before the first navigation.
stealth(
driver,
user_agent=USER_AGENT,
languages=["en-US", "en"],
vendor="Google Inc.",
platform="Win32",
webgl_vendor="Google Inc. (Intel)",
renderer="ANGLE (Intel, Intel(R) UHD Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)",
fix_hairline=True,
)
return driver
def read_fingerprint(driver: webdriver.Chrome) -> dict[str, str]:
"""Return the values a detection script would read from this session."""
return driver.execute_script(
"""
const gl = document.createElement('canvas').getContext('webgl');
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
return {
webdriver: String(navigator.webdriver),
platform: navigator.platform,
languages: navigator.languages.join(','),
plugins: String(navigator.plugins.length),
vendor: gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL),
renderer: gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL),
};
"""
)
if __name__ == "__main__":
driver = build_driver()
try:
driver.get("https://bot.sannysoft.com/")
for key, value in read_fingerprint(driver).items():
print(f"{key:10s} {value}")
finally:
driver.quit()
A correct run prints webdriver undefined, a non-zero plugin count, and the WebGL strings you supplied. If webdriver comes back as true, the stealth() call ran after a navigation or the options object was not the one passed to the driver.
The Errors You Will Actually Hit
Three failures account for most of the time lost here, and each has a distinct signature.
SessionNotCreatedException: This version of ChromeDriver only supports Chrome version N means the driver and the browser have diverged. On a machine with Selenium Manager this usually resolves itself on the next run; in a container that pins both, bump them together rather than one at a time.
WebDriverException: unknown error: cannot determine loading status almost always means Chrome crashed at startup, and in containers the cause is nearly always a missing --no-sandbox or an undersized /dev/shm. Add --disable-dev-shm-usage before you start suspecting the stealth layer.
TypeError: stealth() got an unexpected keyword argument means the installed release does not know that parameter. The library's argument list has changed across versions; check the signature of the installed package rather than trusting a tutorial. This is also why pinning an exact version in your lockfile is worth the small friction.
A fourth failure is quieter and worth naming because it wastes the most time. driver.execute_script returning None for the WebGL values in the verification helper does not mean the patches failed โ it means the container has no GPU and no software renderer, so getContext('webgl') returned null before any patching was relevant. Add --use-gl=swiftshader (or --enable-unsafe-swiftshader on newer builds) and the context appears. A session that reports no WebGL context at all is itself unusual, since a real desktop browser always has one, so this is worth fixing rather than working around in the parser.
Version Drift, and How to Notice It Early
Every value in the configuration above is a snapshot of one browser build. Chrome ships a new stable major roughly every four weeks, and the spoofed strings do not move with it.
Drift is dangerous because it is silent. A stale User-Agent does not raise an exception; it just makes the challenge rate creep up over weeks until someone notices the scrape is returning fewer rows. Two habits catch it early. First, treat the User-Agent string as a dependency: store it in one place, and update it in the same change that upgrades the pinned Chrome or Playwright version. Second, log the HTTP status distribution per run and alert on a change in the ratio of 200s to challenges, not on total failures โ the transition is gradual, so a threshold on the absolute count will fire far too late.
It is also worth being honest about the ceiling. selenium-stealth was written against a specific generation of detection scripts and has not moved as quickly as the vendors have. If a target consistently defeats a correctly configured session, the answer is usually a different tool rather than more arguments โ see undetected-chromedriver vs playwright-stealth for how the two main alternatives differ.
Edge Cases and Caveats
- Headless still leaks.
--headless=newclosed most of the old gaps, but font availability, GPU-backed compositing, and media-codec support still differ from a headed session on the same machine. If a target defeats you only in headless mode, that difference is where to look first. - The patches are per-document, not per-driver. They reapply on navigation because CDP re-runs the injected script โ but if you create a second tab or a fresh context, confirm it inherited them rather than assuming it did.
- Proxy geography must match the locale. An IP geolocating to Frankfurt with
languages=["en-US", "en"]and a UTC-5 timezone is a contradiction. Set--langand the timezone through CDP alongside the proxy, using the pool discipline in Rotating Proxies and Managing IP Blocks. - Do not patch
navigator.webdriverby hand as well. Redefining a property that the library has already redefined can throw in strict mode and leaves a non-standard property descriptor that is itself detectable. - The TLS handshake is untouched. Selenium drives a real Chrome, so the handshake is genuine โ but any plain
requestscall you make alongside it is not, and mixing the two within one logical session is a common leak. The mechanics are covered in TLS and JA3 Fingerprint Evasion. - Stealth is not a CAPTCHA bypass. Reducing heuristic suspicion lowers how often a challenge appears; it never solves one. That distinction is spelled out in Solving CAPTCHAs with Python.
Frequently Asked Questions
Does the stealth() call have to run before driver.get()? Yes, and this is the single most common mistake. The patches are registered through the DevTools Protocol so that Chrome executes them before any page script in each new document. If you navigate first, the detection script on that page has already read the unpatched values, and applying the patches afterwards cannot retract what was measured.
Which platform and WebGL values should I use?
Use values that describe one plausible machine and match your User-Agent. For a Windows Chrome User-Agent, platform="Win32" with a Google Inc. (Intel) vendor and an ANGLE Direct3D renderer string is realistic. Copy them from a real browser on the platform you are claiming, rather than reusing the macOS Intel Iris defaults that circulate in older examples.
How do I tell whether my configuration has gone stale? Watch the ratio of successful responses to challenges over time rather than the absolute failure count, because drift raises the challenge rate gradually. Re-check the User-Agent against the current Chrome stable release whenever you upgrade the browser image, and re-run a fingerprint test page as part of that upgrade.
Is selenium-stealth enough on its own? Rarely. It addresses browser-property signals only. IP reputation, request pacing, TLS characteristics of any non-browser HTTP calls you make, and behavioural timing are all outside its scope, and on a well-defended target those matter more than the properties it does patch.
Related
- Mastering Selenium for Dynamic Websites โ the parent topic this page sits under.
- Explicit vs Implicit Waits in Selenium โ waiting on real conditions instead of fixed sleeps.
- Browser Fingerprint and Stealth Configuration โ the full surface a fingerprint covers.
- How to Rotate User Agents in Python โ keeping the header set coherent with the identity you claim.