Selenium is the oldest and best-documented browser automation framework, which makes it the easiest to detect. Most "stealth Selenium" setups in production are running undetected-chromedriver, which patches a specific list of known tells. The detectability tradeoff is straightforward: defaults are detected in a single line of JavaScript, undetected-chromedriver buys you some time, and a determined modern bot detector catches both. This article walks through the specific signals at each layer.
For the broader framing on what bot detection looks like in 2026, bot detection is the parent post, and headless browser detection covers what the frameworks share. For the Puppeteer and Playwright equivalents, see Puppeteer bot detection and Playwright bot detection.
What “Selenium bot” means
Selenium is a browser automation toolkit. The Selenium WebDriver protocol drives a real browser instance through a separate driver binary: chromedriver for Chrome and Chromium, geckodriver for Firefox, safaridriver for Safari, msedgedriver for Edge. The application code (in Python, Java, JavaScript, or another supported binding) sends commands to the driver over a local HTTP API, and the driver translates them into browser-internal operations.
That architecture leaves traces in three places:
- The driver itself.
chromedriverinjects variables and behavior into the browser that real Chrome users never see. - The W3C WebDriver standard. The standard mandates exposing
navigator.webdriver === true, by design, so that sites can opt-in to detecting automation. - The default automation flags. Chrome with automation enabled sets a small number of properties differently from regular Chrome.
A bot detector that wants to catch Selenium reads the traces. A bot operator that wants to avoid detection patches them out, one by one. Both sides have been doing this for about a decade.
The default-Selenium tells (1 line of JS each)
If you do nothing to hide Selenium, every modern bot detector will flag the session within the first few hundred milliseconds. The checks are this cheap:
1. navigator.webdriver
navigator.webdriver === true
The W3C WebDriver spec mandates this. Real browsers return false (or, in Firefox under certain flag-driven configurations, undefined). Chrome under Selenium returns true (ZenRows: Modify Selenium navigator.webdriver).
This is the single most-checked flag in bot detection. It is also the easiest to spoof, by either passing the --disable-blink-features=AutomationControlled Chrome flag, or by patching the property post-load using Chrome DevTools Protocol’s Page.addScriptToEvaluateOnNewDocument. Both leave their own traces.
2. The $cdc_ variable
Object.keys(document).some((k) => k.startsWith('$cdc_'))
ChromeDriver injects a uniquely-named variable into the document object. The default string for years was $cdc_asdjflasutopfhvcZLmcfl_, hard-coded into the ChromeDriver binary. Anti-bot scripts scan for any property starting with $cdc_ and treat its presence as proof of ChromeDriver.
Patching this out requires editing the ChromeDriver binary to randomise the variable name or remove it entirely. Undetected-chromedriver does this automatically. A custom-compiled ChromeDriver that omits the variable accomplishes the same.
3. The $wdc_ variable
Less common than $cdc_ but documented in older detection scripts. Same principle: another driver-injected marker.
4. The cdc_adoQpoasnfa76pfcZLmcfl_Array global
typeof window.cdc_adoQpoasnfa76pfcZLmcfl_Array !== 'undefined'
Another ChromeDriver injection target with a similarly randomised-looking name. Undetected-chromedriver strips this too.
5. The User-Agent
By default a Selenium-driven Chrome in headless mode reports a User-Agent containing HeadlessChrome instead of Chrome. A site does not need fingerprinting to catch this:
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ... HeadlessChrome/137.0.0.0 ...
The fix is to launch Chrome headed (with a graphical display, or under Xvfb on Linux), or to override the User-Agent. Overriding the UA does not fix all the other artefacts that come with headless mode.
6. Missing or empty navigator.plugins
In normal Chrome, navigator.plugins.length is typically 5: the PDF viewer, Chrome PDF Plugin, Native Client, and a couple of internal entries. In headless or in Selenium with default flags, the array is often empty.
7. Missing chrome.runtime
typeof window.chrome === 'undefined' || typeof window.chrome.runtime === 'undefined'
Real Chrome has window.chrome.runtime defined as an object. Headless Chrome historically did not, though this has been changing.
8. Console.debug timing
If you call console.debug() with a getter that throws, the way Chrome handles the throw differs between regular and headless modes. This is one of the more subtle checks that survives most spoofing patches; CDP detection covers this family of probes in depth.
Leave any of these in place and a site running even a basic detection script (Cloudflare’s, DataDome’s, or any of the open-source detectors on GitHub) will catch the session in the first second.
What undetected-chromedriver actually patches
The popular undetected-chromedriver Python library, by Ultrafunkamsterdam, patches the ChromeDriver binary at runtime to remove the most prominent tells (GitHub: undetected-chromedriver). Specifically:
- Strips
$cdc_*variable injection from the ChromeDriver binary. - Strips
cdc_adoQpoasnfa76pfcZLmcfl_*global definitions. - Launches Chrome with
--disable-blink-features=AutomationControlledsonavigator.webdriverisfalse. - Suppresses the “Chrome is being controlled by automated test software” infobar.
- Passes flags to avoid some default headless artefacts.
What it does not do:
- Patch the TLS fingerprint. It does not need to: ChromeDriver launches real Chrome, so the TLS layer is genuinely Chrome’s. But a clean Chrome TLS fingerprint paired with the environment, infrastructure, and behavioral tells below is itself a distinctive combination.
- Patch behavioral signals. The session still has no mouse movement, no scroll history, no organic interaction.
- Patch CDP usage. Selenium still talks to Chrome via the Chrome DevTools Protocol, which creates its own observable events inside the browser.
- Patch the browser environment more deeply than the named tells. New tells get discovered regularly.
The library is in active maintenance because every Chrome version introduces small changes that expose new tells. The defender side is engaged in the same cycle from the other direction.
Server-side signals that catch undetected-chromedriver
When a bot detector cannot rely on navigator.webdriver because the operator has patched it, the next layers of evidence are at the network and behavioral layers.
TLS fingerprint with full headed environment
A headed Chrome on Linux running under Xvfb produces a TLS fingerprint identical to a real headed Chrome on Linux. But the combination of:
- Real Chrome TLS fingerprint
- Datacenter IP (Hetzner, OVH, AWS)
- No referer for the first request to a deep URL
- A request pattern with no idle gaps between page views
is not produced by real users in any meaningful volume. Linux desktop Chrome from a Hetzner /24 with sub-second page-to-page intervals is automation regardless of whether it has been patched.
Chrome DevTools Protocol detection
The CDP itself is observable from within the browser. Even when no specific tells like $cdc_ are present, the fact that CDP is connected can be inferred by:
Runtime.evaluatecalls that happen too fast for a real user.- A specific timing fingerprint on
console.logcalls when the dev tools are attached. - Specific
Debugger.scriptParsedevents that fire on every script load. - Subtle differences in how exceptions are thrown when DevTools is connected.
The DataDome research team has written a detailed post on the specific CDP-attached checks that distinguish even a patched undetected-chromedriver session from a real Chrome session (DataDome: Detecting Selenium Chrome).
Behavioral absence
The single most expensive thing for a Selenium operator to fake is convincing behavioral data. A real user produces mouse-move events for every transition. They scroll, sometimes back up. They focus a form field, type briefly, hesitate, type again. They occasionally hit the wrong key.
Selenium scripts that do not invest in simulating this leave behavioral signal at zero, which is itself a distinctive signal. Scripts that invest in synthetic behavioral data still produce patterns with characteristic regularity (uniform intervals, perfect Bezier curves, fixed dwell times) that do not match real human distributions.
Cluster behavior
A single Selenium operator running a moderate-volume operation typically produces a cluster of sessions with consistent properties: the same library, the same proxy provider, the same target endpoints, the same hour-of-day patterns. The session-level fingerprint may look unique each time, but the cluster signature does not.
Chrome’s X-Browser-Validation header
Recent Chrome releases (144 and later, current at the time of writing) ship an X-Browser-Validation header with a rotatable seed. The intent is bot-detection-adjacent: real Chrome can compute a value the server can verify against the seed; a non-Chrome client cannot (AlterLab: Selenium bot detection). The rotatable seed makes reverse-engineering an ongoing maintenance burden for stealth operators.
A practical detection checklist
If you are implementing Selenium detection, the order of checks by cost and signal value:
-
Cheap server checks first.
- JA4 TLS fingerprint inconsistent with claimed UA.
- User-Agent contains
HeadlessChrome. - IP from a hosting/VPS ASN paired with a claimed consumer browser.
- HTTP/2 SETTINGS frame from a non-browser client.
-
JS environment checks.
navigator.webdriver === true.$cdc_*properties ondocument.cdc_*and similar globals onwindow.navigator.plugins.length === 0on a non-mobile UA.window.chromeandwindow.chrome.runtimeshape checks.
-
CDP attachment checks.
- Timing tells on
console.debug. - Specific exception-handling tells.
- The Runtime.evaluate access pattern.
- Timing tells on
-
Behavioral absence.
- Time-to-first-interaction over reasonable thresholds.
- Zero mouse-move events between page loads.
- Zero scroll events on a page longer than viewport.
-
Cluster signals.
- Multiple sessions sharing improbable joint distributions on TLS, fingerprint, IP class, and request timing.
The signals themselves are public; the exact thresholds and combinations that hold up in production are what bot detection vendors are paid for.
One note for legitimate testers: if you run Selenium for QA against your own properties, none of these tells are a problem to patch. Register the automation in your bot management allow-list instead, and let detection effort stay focused on traffic that hides.
How Foil detects Selenium
Foil’s classification surface includes framework.selenium and several sub-variants depending on the patch level (framework.selenium.undetected-chromedriver, framework.selenium.stealth-headless). The classification is based on the joint distribution of:
- Server-side TLS and HTTP signals.
- Browser-environment checks for the named tells above.
- CDP attachment detection.
- Behavioral snapshot.
A session classified as framework.selenium arrives at the application with a verdict and an explanation listing which signals fired. The application then decides what to do (allow for testing on staging, block on production, throttle for ambiguous cases).
Further reading
- DataDome, Detecting Selenium Chrome: datadome.co/threat-research
- AlterLab, Selenium bot detection in 2026: alterlab.io/blog
- Ultrafunkamsterdam, undetected-chromedriver (source): github.com/ultrafunkamsterdam/undetected-chromedriver
- ZenRows, Modify Selenium navigator.webdriver: zenrows.com/blog/navigator-webdriver