Selenium Bot Detection: Why You Get Caught and How to Avoid It
Selenium leaks over a dozen browser signals that anti-bot systems detect instantly. This guide covers every detection vector from navigator.webdriver to CDP markers, with working Python fixes for each one.
Yash Dubey
February 19, 2026
Every time you launch a Selenium script against a production website, you are walking into a room full of tripwires. Modern anti-bot systems do not look for one signal. They look for dozens, and Selenium hands them over willingly.
This guide covers every way Selenium leaks its identity, the specific fix for each leak, and how to build a stealth configuration that survives real-world detection. Code is Python 3.11+ with Selenium 4.
Why Selenium Gets Detected
Anti-bot systems like Cloudflare, DataDome, PerimeterX, and Akamai Bot Manager run JavaScript fingerprinting on every page load. They inspect your browser environment for inconsistencies that no real user would have. Selenium introduces many of these inconsistencies by default.
The detection is not a single check. It is a scoring system. Each leaked signal adds points. Cross a threshold, and you get a CAPTCHA, a 403, or an infinite redirect loop.
The Detection Signals: Everything Selenium Leaks
1. The navigator.webdriver Flag
This is the most well-known detection vector. When ChromeDriver controls a browser, it sets navigator.webdriver to true. Every real browser returns false or undefined.
from selenium import webdriver
driver = webdriver.Chrome()
driver.get("https://example.com")
# This returns True in Selenium-controlled browsers
result = driver.execute_script("return navigator.webdriver")
print(result) # True -- you are caughtAnti-bot scripts check this property before the page even finishes loading. It is usually the first thing they test.
The fix:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.add_argument("--disable-blink-features=AutomationControlled")
driver = webdriver.Chrome(options=options)
# Override the property via CDP
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": "Object.defineProperty(navigator, 'webdriver', { get: () => undefined });"
})The --disable-blink-features=AutomationControlled flag prevents Chrome from setting the flag in the first place. The CDP override catches any edge cases where it leaks through iframes or service workers.
2. ChromeDriver cdc_ Marker Variables
ChromeDriver injects variables into the document object that start with cdc_ (e.g., cdc_adoQpoasnfa76pfcZLmcfl_Array). These are internal communication channels between ChromeDriver and the browser. No regular Chrome session has them.
Detection code looks like this on the anti-bot side:
// What anti-bot scripts run against your browser
let dominated = false;
for (let key in document) {
if (key.match(/^cdc_/)) {
dominated = true;
break;
}
}The fix:
The cleanest approach is to patch the ChromeDriver binary. This is fragile and version-specific, so most engineers use undetected-chromedriver instead, which handles this automatically:
import undetected_chromedriver as uc
driver = uc.Chrome(version_main=124)
driver.get("https://nowsecure.nl")undetected-chromedriver patches the ChromeDriver binary at runtime, renaming cdc_ variables to random strings. It also handles several other detection vectors automatically.
3. Missing Browser Plugins
A real Chrome browser reports plugins like Chrome PDF Viewer, Native Client, and Widevine Content Decryption Module. Headless Chrome reports an empty plugin array.
# Detection check
plugins = driver.execute_script("return navigator.plugins.length")
# Real Chrome: 3-5 plugins
# Headless Chrome: 0 pluginsThe fix:
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": """
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
{ name: 'Native Client', filename: 'internal-nacl-plugin' }
]
});
"""
})4. User-Agent String Mismatches
The default Selenium user-agent contains HeadlessChrome when running in headless mode. Even in headed mode, the version string can be inconsistent with the actual Chrome binary.
# Headless default UA contains "HeadlessChrome"
ua = driver.execute_script("return navigator.userAgent")
# Mozilla/5.0 ... HeadlessChrome/124.0.6367.91 ...The fix:
options = Options()
options.add_argument("--headless=new")
options.add_argument(
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.6367.91 Safari/537.36"
)But the user-agent is only one piece. Anti-bot systems cross-reference it with other signals. If your UA says Windows but navigator.platform says Linux, you are flagged.
5. The Headless vs Headed Detection Matrix
Running --headless changes more than just the user-agent. Anti-bot scripts probe multiple properties to distinguish headless from headed mode.
| Feature | Property | Headed Chrome | Headless Chrome |
|---|---|---|---|
| navigator.webdriver | false | true | |
| navigator.plugins.length | 3-5 | 0 | |
| navigator.languages | en-US and en | empty string | |
| window.chrome | object | undefined | |
| Notification.permission | default | denied (always) | |
| navigator.userAgent | Chrome/XXX | HeadlessChrome/XXX | |
| WebGL renderer | GPU name | SwiftShader | |
| window.outerWidth | greater than 0 | 0 |
Every one of these mismatches is a detection signal. Fixing only one or two still leaves you exposed.
6. WebGL and Canvas Fingerprinting
Headless Chrome uses SwiftShader for WebGL rendering. Anti-bot scripts query UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL to check for this.
# What detection scripts extract
renderer = driver.execute_script("""
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
const ext = gl.getExtension('WEBGL_debug_renderer_info');
return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
""")
# Real Chrome: "ANGLE (NVIDIA GeForce GTX 1080 ...)"
# Headless: "Google SwiftShader"The fix:
There is no clean JavaScript override for this. The most reliable approach is to use headed mode with a virtual display (Xvfb on Linux):
# On Linux, use xvfb for a virtual display
# pip install PyVirtualDisplay
from pyvirtualdisplay import Display
display = Display(visible=0, size=(1920, 1080))
display.start()
options = Options()
# Do NOT use --headless. Use the virtual display instead.
driver = webdriver.Chrome(options=options)This gives you a real GPU-accelerated rendering context without needing a physical monitor.
7. HTTP Header Anomalies
Selenium sends HTTP headers in a different order than real browsers, and it often omits headers like sec-ch-ua, sec-ch-ua-mobile, and sec-fetch-dest that modern Chrome always sends.
| Feature | Header | Real Chrome | Selenium Default |
|---|---|---|---|
| sec-ch-ua | Present with brand hints | Missing | |
| sec-ch-ua-platform | Windows or macOS | Missing | |
| sec-fetch-site | none or same-origin | Missing | |
| Accept-Language | en-US with quality values | Bare en-US and en | |
| Header order | Consistent per version | Varies by driver version |
The fix using CDP Network.setExtraHTTPHeaders:
driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {
"headers": {
"sec-ch-ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"Accept-Language": "en-US,en;q=0.9",
}
})8. Automation-Related Chrome Flags
Chrome exposes several flags via chrome://flags and the DevTools protocol that reveal automation. The enable-automation flag, in particular, adds an info bar that says "Chrome is being controlled by automated test software" and sets detectable properties.
options = Options()
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)9. JavaScript Environment Leaks
Beyond navigator.webdriver, there are subtler JavaScript environment differences:
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": """
// Fix window.chrome
window.chrome = {
runtime: {},
loadTimes: function() {},
csi: function() {},
app: { isInstalled: false }
};
// Fix permissions API
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
// Fix languages
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en']
});
"""
})The Complete Stealth Setup
Here is a full stealth configuration that addresses every detection vector covered above.
Install Dependencies
pip install selenium undetected-chromedriver selenium-stealth PyVirtualDisplay
Configure Browser Options
Disable automation flags, set realistic user-agent, configure window size
Inject CDP Overrides
Patch navigator properties, plugins, permissions, and Chrome object before page load
Set Network Headers
Add sec-ch-ua headers, fix Accept-Language, match header order to real Chrome
Add Human Behavior
Random delays, mouse movements, scroll patterns, and viewport resizing
Option A: Using selenium-stealth
The selenium-stealth library automates many of the patches described above:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium_stealth import stealth
options = Options()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option("useAutomationExtension", False)
options.add_argument(
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.6367.91 Safari/537.36"
)
driver = webdriver.Chrome(options=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("https://bot.sannysoft.com")Option B: Using undetected-chromedriver
For sites with aggressive detection (Cloudflare, DataDome), undetected-chromedriver is more effective because it patches the ChromeDriver binary itself:
import undetected_chromedriver as uc
import time
options = uc.ChromeOptions()
options.add_argument("--window-size=1920,1080")
options.add_argument(
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.6367.91 Safari/537.36"
)
driver = uc.Chrome(options=options, version_main=124)
# Add extra CDP patches for thorough coverage
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": """
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en']
});
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin' },
{ name: 'Chrome PDF Viewer' },
{ name: 'Native Client' }
]
});
"""
})
driver.get("https://nowsecure.nl")
time.sleep(5)
# Check if we passed
title = driver.title
print(f"Page title: {title}")
driver.quit()Option C: Headless Firefox as an Alternative
Firefox with geckodriver has fewer default leaks than Chrome. The navigator.webdriver flag is the primary concern, and it can be disabled via preferences:
from selenium import webdriver
from selenium.webdriver.firefox.options import Options
options = Options()
options.add_argument("--headless")
options.set_preference("dom.webdriver.enabled", False)
options.set_preference("useAutomationExtension", False)
options.set_preference(
"general.useragent.override",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) "
"Gecko/20100101 Firefox/125.0"
)
driver = webdriver.Firefox(options=options)
driver.get("https://bot.sannysoft.com")Firefox lacks the cdc_ variable problem and the window.chrome detection vector entirely, making it a cleaner starting point for stealth scraping. However, some sites specifically check for Chrome-like features and will flag Firefox-based access as suspicious if combined with other anomalies.
Behavioral Detection: The Layer You Cannot Patch
Even with a perfectly patched browser fingerprint, modern anti-bot systems use behavioral analysis. They measure:
- Mouse movement patterns: Real users move the mouse in curved, imprecise paths. Bot scripts move in straight lines or not at all.
- Scroll behavior: Real users scroll in variable increments with momentum. Bots jump to elements instantly.
- Timing patterns: Real users have variable delays between actions. Bots operate at consistent intervals.
- Viewport interaction: Real users resize windows, switch tabs, and trigger focus/blur events.
import random
import time
from selenium.webdriver.common.action_chains import ActionChains
def human_like_delay(min_sec=0.5, max_sec=2.0):
time.sleep(random.uniform(min_sec, max_sec))
def human_scroll(driver):
total_height = driver.execute_script("return document.body.scrollHeight")
current = 0
while current < total_height:
scroll_amount = random.randint(200, 600)
driver.execute_script(f"window.scrollBy(0, {scroll_amount});")
current += scroll_amount
human_like_delay(0.3, 1.2)
def human_mouse_move(driver, element):
actions = ActionChains(driver)
x_offset = random.randint(-5, 5)
y_offset = random.randint(-5, 5)
actions.move_to_element_with_offset(element, x_offset, y_offset)
actions.perform()
human_like_delay(0.1, 0.4)Detection Test: How to Verify Your Setup
Before running your scraper against a target, test it against known detection pages:
# Quick detection test
import undetected_chromedriver as uc
driver = uc.Chrome(version_main=124)
driver.get("https://bot.sannysoft.com")
# Screenshot the results
driver.save_screenshot("/tmp/detection_test.png")
# Check specific values
webdriver_flag = driver.execute_script("return navigator.webdriver")
chrome_obj = driver.execute_script("return typeof window.chrome")
plugins_count = driver.execute_script("return navigator.plugins.length")
print(f"webdriver: {webdriver_flag}") # Should be: undefined/False
print(f"chrome: {chrome_obj}") # Should be: object
print(f"plugins: {plugins_count}") # Should be: 3+
driver.quit()When Stealth Selenium Is Not Enough
There is a hard ceiling to what browser patching can achieve. Anti-bot vendors update their detection methods continuously. A setup that works today may fail next week when Cloudflare ships a new fingerprinting check.
The fundamental problem: you are playing defense against a system that knows every possible automation tool. Every patch is a reaction to a detection that already exists.
At scale, maintaining stealth Selenium becomes an ongoing engineering burden:
- ChromeDriver updates break existing patches
- New fingerprinting techniques require research and counter-patches
- IP reputation means proxies must be rotated and managed
- CAPTCHA solving adds cost and latency
- Memory and CPU costs scale linearly with concurrent sessions
For production scraping workloads, a dedicated scraping API handles the anti-bot bypass at the infrastructure level. AlterLab, for example, maintains rotating browser fingerprints, residential proxies, and anti-bot bypass logic as a managed service -- you send a URL and get back clean HTML without managing any browser infrastructure.
import requests
# Instead of managing Selenium stealth patches:
response = requests.post(
"https://alterlab.io/api/v1/scrape",
headers={"X-API-Key": "your_api_key"},
json={"url": "https://example.com", "formats": ["html", "markdown"]}
)
data = response.json()
html_content = data["results"]["html"]This is not about choosing one approach over the other. Stealth Selenium is the right tool for low-volume scraping, testing, and development. A scraping API is the right tool for production workloads where reliability and maintenance cost matter more than per-request cost.
Summary: Detection Vectors and Fixes
| Feature | Detection Vector | Risk Level | Fix |
|---|---|---|---|
| navigator.webdriver | Critical | CDP override + Chrome flag | |
| cdc_ variables | Critical | undetected-chromedriver | |
| Empty plugins array | High | JavaScript property override | |
| HeadlessChrome UA | High | Custom user-agent string | |
| Missing sec-ch-ua headers | Medium | CDP Network.setExtraHTTPHeaders | |
| SwiftShader WebGL | Medium | Xvfb virtual display (headed mode) | |
| window.chrome missing | Medium | JavaScript property injection | |
| Behavioral patterns | High | Randomized human-like interactions |
The detection game is asymmetric. Defenders only need to find one signal you missed. Attackers need to patch every signal perfectly, across every page load, across every Chrome version update. Understanding the full scope of what Selenium leaks is the first step toward either patching it effectively or deciding to use a tool that handles it for you.