Post

selenium-stealth: Making Selenium Less Detectable

selenium-stealth: Making Selenium Less Detectable

If you have used Selenium for any serious web scraping, you have probably hit a wall where the target site blocks you instantly. The reason is simple: a default Selenium Chrome session leaks dozens of signals that scream “I am a bot.” The navigator.webdriver property is set to true, the plugin list is empty, the Chrome runtime object is missing key properties, and permissions queries return inconsistent results. Anti-bot systems like Cloudflare, DataDome, and PerimeterX check for all of these. The selenium-stealth package is a lightweight Python library that patches many of these signals, making your Selenium browser look more like a real user’s Chrome. It is not a silver bullet, but for a wide range of sites, it is all you need. For a broader look at how detection has evolved over time, see the evolution of web scraping detection methods.

What Is selenium-stealth

selenium-stealth is a Python package that applies a series of JavaScript patches to a Selenium-controlled Chrome browser. It was inspired by the puppeteer-extra-plugin-stealth package from the Puppeteer ecosystem and brings similar evasion techniques to Selenium users. The library works by injecting scripts into the browser session that override known automation fingerprints before the target page’s detection scripts can read them.

The project lives on PyPI and can be installed in seconds. It targets Chrome specifically — Firefox and other browsers are not supported.

Installation

Install with pip:

1
pip install selenium-stealth

You will also need Selenium and a compatible ChromeDriver:

1
pip install selenium

Make sure your ChromeDriver version matches your installed Chrome version. If you are using Selenium 4.6 or later, the built-in Selenium Manager handles this automatically.

Basic Setup

The core API is a single function: stealth(). You pass it your Selenium WebDriver instance along with configuration options that control the fake fingerprint values.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium_stealth import stealth

# Set up Chrome options
chrome_options = Options()
chrome_options.add_argument("--start-maximized")
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
chrome_options.add_experimental_option("useAutomationExtension", False)

# Create the driver
driver = webdriver.Chrome(options=chrome_options)

# Apply stealth patches
stealth(driver,
    languages=["en-US", "en"],
    vendor="Google Inc.",
    platform="Win32",
    webgl_vendor="Intel Inc.",
    renderer="Intel Iris OpenGL Engine",
    fix_hairline=True,
)

# Now use the driver as normal
driver.get("https://example.com")
print(driver.title)
driver.quit()

The excludeSwitches and useAutomationExtension options in the Chrome configuration are not part of selenium-stealth itself, but they complement it. The first removes the “Chrome is being controlled by automated test software” banner and its associated detection signal. The second disables Chrome’s built-in automation extension.

What selenium-stealth Patches

The library targets the most commonly checked automation fingerprints. Here is what it modifies:

This is the single most basic bot detection check. When Chrome is controlled by Selenium, navigator.webdriver returns true. Real browsers return false or undefined. selenium-stealth overrides this property to return false.

1
2
# Before stealth: navigator.webdriver = true
# After stealth:  navigator.webdriver = false

An empty or single-entry language list is suspicious. selenium-stealth sets this to whatever you configure, defaulting to a realistic value:

1
stealth(driver, languages=["en-US", "en"])

This makes navigator.languages return ["en-US", "en"] instead of an empty or default list.

A real Chrome browser has plugins like Chrome PDF Plugin and Chrome PDF Viewer. Selenium sessions typically report zero plugins. selenium-stealth injects fake plugin objects that mimic the structure and properties of real Chrome plugins.

Real Chrome reports "Google Inc." as the vendor. Some Selenium configurations leave this empty or set it to an unexpected value. selenium-stealth lets you set it explicitly:

1
stealth(driver, vendor="Google Inc.")

WebGL Renderer and Vendor

Detection scripts use WebGL to query the graphics hardware and driver information. Mismatches between the reported renderer and the platform you claim to be on are a strong signal. selenium-stealth lets you specify both:

1
2
3
4
stealth(driver,
    webgl_vendor="Intel Inc.",
    renderer="Intel Iris OpenGL Engine",
)

Choose values that are consistent with the platform you are spoofing. If you set platform="Win32", use a renderer string that actually exists on Windows systems.

chrome.runtime

Real Chrome browsers have a chrome.runtime object with specific properties. Automated Chrome sessions often have this object missing or incorrectly structured. selenium-stealth patches it to look like a normal browser.

window.chrome

Similar to chrome.runtime, the window.chrome object is expected to exist in a real Chrome session. selenium-stealth ensures it is present with the correct structure.

Permissions API

Detection scripts call navigator.permissions.query() to check the notification permission state. In automated browsers, this often throws an error or returns unexpected values. selenium-stealth patches the Permissions API to return consistent results.

Hairline Fix

The fix_hairline=True option patches a rendering quirk where thin lines (hairlines) are displayed differently in automated Chrome compared to normal Chrome. This is a subtle visual fingerprint that some detection systems check.

What selenium-stealth Does NOT Patch

Understanding the limits is just as important as understanding the capabilities. These are the areas where selenium-stealth offers no protection:

TLS Fingerprint

Every browser has a unique TLS fingerprint based on its cipher suites, extensions, and handshake parameters. Detection systems use JA3 and JA4 hashing to identify the TLS fingerprint before any page content loads. Tools like httpmorph address this at the HTTP client level. selenium-stealth operates at the JavaScript level and has no ability to modify TLS behavior. Your browser’s TLS fingerprint will still match Selenium-controlled Chrome.

Behavioral Analysis

selenium-stealth does not change how you interact with the page. If your scraper navigates instantly, clicks without moving the mouse, or types at perfectly uniform intervals, behavioral analysis systems will flag you regardless of fingerprint patches.

Canvas Fingerprint

While selenium-stealth patches WebGL renderer strings, it does not modify canvas fingerprint output. Detection scripts render invisible canvas elements and hash the pixel output. The hash differs between real and automated browsers due to differences in font rendering, antialiasing, and GPU processing.

CDP (Chrome DevTools Protocol) Markers

Selenium communicates with Chrome through the Chrome DevTools Protocol. Some detection scripts probe for artifacts left by this connection, such as specific properties on the window object or the presence of debugging ports. selenium-stealth does not address these markers.

iframe Consistency

Detection scripts sometimes load checks inside iframes to see if the stealth patches propagate. Some JavaScript-level patches only apply to the main frame, leaving iframes unpatched. This inconsistency is itself a detection signal.

Testing Before and After

You can verify the effect of selenium-stealth by checking key properties before and after applying it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium_stealth import stealth

def check_fingerprint(driver):
    """Check common bot detection properties."""
    checks = {
        "navigator.webdriver": driver.execute_script("return navigator.webdriver"),
        "navigator.languages": driver.execute_script("return navigator.languages"),
        "navigator.vendor": driver.execute_script("return navigator.vendor"),
        "plugins_count": driver.execute_script("return navigator.plugins.length"),
        "chrome_exists": driver.execute_script("return !!window.chrome"),
        "chrome_runtime": driver.execute_script(
            "return typeof window.chrome !== 'undefined' && "
            "typeof window.chrome.runtime !== 'undefined'"
        ),
        "webgl_vendor": driver.execute_script(
            "var canvas = document.createElement('canvas');"
            "var gl = canvas.getContext('webgl');"
            "if (!gl) return 'no webgl';"
            "var ext = gl.getExtension('WEBGL_debug_renderer_info');"
            "return ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : 'no ext';"
        ),
        "webgl_renderer": driver.execute_script(
            "var canvas = document.createElement('canvas');"
            "var gl = canvas.getContext('webgl');"
            "if (!gl) return 'no webgl';"
            "var ext = gl.getExtension('WEBGL_debug_renderer_info');"
            "return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : 'no ext';"
        ),
    }
    return checks


# Without stealth
chrome_options = Options()
chrome_options.add_argument("--headless=new")
driver = webdriver.Chrome(options=chrome_options)
driver.get("about:blank")

print("=== WITHOUT stealth ===")
for key, value in check_fingerprint(driver).items():
    print(f"  {key}: {value}")
driver.quit()

# With stealth
chrome_options = Options()
chrome_options.add_argument("--headless=new")
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
chrome_options.add_experimental_option("useAutomationExtension", False)
driver = webdriver.Chrome(options=chrome_options)

stealth(driver,
    languages=["en-US", "en"],
    vendor="Google Inc.",
    platform="Win32",
    webgl_vendor="Intel Inc.",
    renderer="Intel Iris OpenGL Engine",
    fix_hairline=True,
)

driver.get("about:blank")
print("\n=== WITH stealth ===")
for key, value in check_fingerprint(driver).items():
    print(f"  {key}: {value}")
driver.quit()

Typical output looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
=== WITHOUT stealth ===
  navigator.webdriver: True
  navigator.languages: ['en-US']
  navigator.vendor: Google Inc.
  plugins_count: 0
  chrome_exists: False
  chrome_runtime: False
  webgl_vendor: Google Inc. (...)
  webgl_renderer: ANGLE (...)

=== WITH stealth ===
  navigator.webdriver: False
  navigator.languages: ['en-US', 'en']
  navigator.vendor: Google Inc.
  plugins_count: 3
  chrome_exists: True
  chrome_runtime: True
  webgl_vendor: Intel Inc.
  webgl_renderer: Intel Iris OpenGL Engine

The difference in plugins_count, chrome_exists, and navigator.webdriver is what gets you past basic detection systems.

Limitations

selenium-stealth has practical limits you should be aware of before committing to it:

Maintenance cadence. The library is maintained but not frequently updated. Detection systems evolve constantly, adding new checks and refining existing ones. selenium-stealth’s patches target a specific set of known checks, and new detection methods may not be covered until the library is updated.

Chrome only. It works exclusively with Chrome via Selenium. If you need Firefox or Edge support, you will need a different approach.

JavaScript-level only. All patches operate by injecting JavaScript. This means they can be detected by sophisticated systems that compare JavaScript-level values with lower-level browser internals, check for property descriptor tampering, or probe for the presence of override scripts.

No headless-specific patches. Running in headless mode introduces additional fingerprint differences (screen dimensions, rendering behavior). selenium-stealth does not specifically address headless-mode artifacts, though using --headless=new (the newer headless mode) reduces some of these differences.

Static fingerprint. The fingerprint values you set are static for the session. If you run multiple sessions with identical fingerprints, detection systems that track fingerprint uniqueness may flag you.

Alternatives

When selenium-stealth is not enough, these tools offer more comprehensive evasion:

undetected-chromedriver

undetected-chromedriver takes a more aggressive approach than selenium-stealth. Instead of just injecting JavaScript patches, it modifies the ChromeDriver binary itself to remove automation markers at the binary level. It also patches the Chrome executable to remove CDP detection artifacts.

1
2
3
4
import undetected_chromedriver as uc

driver = uc.Chrome(headless=False)
driver.get("https://example.com")

undetected-chromedriver handles more detection vectors than selenium-stealth, including some CDP-level markers. However, it can be slower to start up and occasionally has compatibility issues with the latest Chrome versions.

SeleniumBase UC Mode

SeleniumBase offers an “Undetected Chrome” mode that combines undetected-chromedriver with additional stealth patches and a higher-level API:

1
2
3
4
5
6
from seleniumbase import SB

with SB(uc=True) as sb:
    sb.open("https://example.com")
    sb.type("#search", "query")
    sb.click("button[type='submit']")

SeleniumBase UC Mode is the most comprehensive Selenium-based stealth solution. It handles driver patching, runtime patches, and provides utility methods for solving CAPTCHAs. If you want to stay in the Selenium ecosystem but need stronger evasion, this is the natural upgrade path.

Nodriver

For the strongest stealth available in Python, nodriver bypasses Selenium entirely. It communicates with Chrome through the raw DevTools Protocol without any WebDriver dependency, which eliminates the entire class of WebDriver-based detection:

1
2
3
4
5
6
7
8
9
import nodriver as uc

async def main():
    browser = await uc.start()
    page = await browser.get("https://example.com")
    content = await page.get_content()
    print(content)

uc.loop().run_until_complete(main())

When selenium-stealth Is Enough

selenium-stealth is the right tool when:

  • The target site uses basic bot detection that checks navigator.webdriver, plugin counts, and similar JavaScript properties
  • You are scraping medium-security sites that do not employ Cloudflare, DataDome, or PerimeterX
  • You need a quick drop-in solution that does not require rewriting your existing Selenium code
  • The site uses simple rate limiting combined with basic fingerprint checks
  • You are building a prototype and want to test whether JS-level patches are sufficient before investing in heavier tools

A good rule of thumb: if you can access the site manually in Chrome DevTools (with the console open and the automation banner showing) without being blocked, selenium-stealth will likely work.

When to Upgrade

You need to move beyond selenium-stealth when:

  • The target site uses Cloudflare Bot Management, DataDome, PerimeterX, or Akamai Bot Manager
  • You are getting blocked despite passing all JavaScript fingerprint checks (this suggests TLS or behavioral detection)
  • The site uses canvas fingerprinting as a primary detection method
  • You see CAPTCHA challenges that appear only for your automated sessions
  • Detection seems to happen before the page loads (indicating TLS-level blocking)

In these cases, consider upgrading to undetected-chromedriver, SeleniumBase UC Mode, or nodriver depending on how much code you are willing to rewrite. For a head-to-head look at how Playwright and Selenium compare on stealth, see our dedicated comparison. The stealth browsers landscape in 2026 covers the full range of options.

Complete Working Example

Here is a full example with error handling that demonstrates a realistic selenium-stealth setup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import time
import logging
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (
    TimeoutException,
    WebDriverException,
)
from selenium_stealth import stealth

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def create_stealth_driver():
    """Create a Selenium Chrome driver with stealth patches applied."""
    chrome_options = Options()

    # Basic Chrome flags for a cleaner fingerprint
    chrome_options.add_argument("--start-maximized")
    chrome_options.add_argument("--disable-blink-features=AutomationControlled")
    chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
    chrome_options.add_experimental_option("useAutomationExtension", False)

    # Optional: set a realistic user agent
    chrome_options.add_argument(
        "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    )

    driver = webdriver.Chrome(options=chrome_options)

    # Apply stealth patches
    stealth(driver,
        languages=["en-US", "en"],
        vendor="Google Inc.",
        platform="Win32",
        webgl_vendor="Intel Inc.",
        renderer="Intel Iris OpenGL Engine",
        fix_hairline=True,
    )

    return driver


def scrape_with_stealth(url, css_selector, timeout=15):
    """
    Navigate to a URL and extract text from elements matching a CSS selector.

    Returns a list of text strings, one per matched element.
    """
    driver = None
    try:
        driver = create_stealth_driver()
        logger.info(f"Navigating to {url}")
        driver.get(url)

        # Wait for the target elements to appear
        WebDriverWait(driver, timeout).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, css_selector))
        )

        # Small delay to let any lazy-loaded content finish
        time.sleep(2)

        elements = driver.find_elements(By.CSS_SELECTOR, css_selector)
        results = [el.text for el in elements if el.text.strip()]

        logger.info(f"Found {len(results)} elements matching '{css_selector}'")
        return results

    except TimeoutException:
        logger.warning(f"Timed out waiting for '{css_selector}' on {url}")
        return []

    except WebDriverException as e:
        logger.error(f"WebDriver error: {e.msg}")
        return []

    except Exception as e:
        logger.error(f"Unexpected error: {e}")
        return []

    finally:
        if driver:
            driver.quit()
            logger.info("Driver closed")


if __name__ == "__main__":
    # Example: scrape heading text from a page
    url = "https://example.com"
    selector = "h1"

    results = scrape_with_stealth(url, selector)
    for i, text in enumerate(results, 1):
        print(f"{i}. {text}")

This example combines selenium-stealth with several complementary techniques: the excludeSwitches option, the AutomationControlled blink feature flag, a realistic user agent string, and proper wait handling. Together, these give you the best chance of passing basic bot detection while keeping the code maintainable and easy to extend.

Summary

selenium-stealth fills a specific niche: it is the fastest way to make Selenium less detectable without rewriting your codebase. Install it, call stealth() on your driver, and you immediately pass the most common JavaScript-level bot checks. But it operates entirely at the JS level, which means it cannot address TLS fingerprints, behavioral analysis, canvas hashing, or CDP artifacts. Know its boundaries, test against your target site, and be ready to upgrade to undetected-chromedriver, SeleniumBase UC Mode, or nodriver when you hit detection that selenium-stealth cannot handle. If you are still weighing Selenium against Puppeteer altogether, the Selenium vs Puppeteer comparison may help you decide.

Contact Arman for Complex Problems
This post is licensed under CC BY 4.0 by the author.