nodriver and undetected-chromedriver: what they patch and why it eventually breaks
Pick almost any Python scraping project that needs to render JavaScript and survive a bot wall, and somewhere in its history is a line that imports undetected_chromedriver. The package promised something specific and concrete: take stock Selenium with Chrome, strip out the parts that announce “I am an automated browser,” and pass the bot walls that block plain Selenium on the first request. For a few years it largely worked. Then it worked less. The same author then shipped a successor, nodriver, that threw out the two pieces the first tool spent the most effort hiding, and for a while that worked better. The question worth asking is not which one to install but why the patch surface keeps moving, and why a tool that wins a 2026 benchmark with zero blocks can still be reading the same losing hand a year later.
This is a tool-lineage post. It follows the line from a patched chromedriver binary to a library that refuses to ship a chromedriver binary at all, and it pays attention to the specific signal each version was built to kill and the specific signal that killed it next. The shape of the story repeats: a tool patches a tell, the tell goes quiet, detection moves one layer down the stack, and the patch that used to matter becomes table stakes.
The sections below go roughly in the order the layers were peeled. First the cdc_ document property, the original tell and the thing the name “undetected-chromedriver” was coined to defeat. Then the binary patch itself and how it changed shape over the project’s life. Then the navigator.webdriver flag and the launch-time automation switches, because those are a separate surface that the same tool also had to handle. Then the architectural break: nodriver dropping both the chromedriver binary and Selenium, and what that bought. Then the layer underneath all of it, the Chrome DevTools Protocol, where the Runtime.enable leak and a 2025 Chrome bug caught everything at once regardless of which wrapper was driving. A closing read on why the cycle is structural.
The cdc_ property: the original tell
Start with the signal that gave the project its name. When ChromeDriver drives a Chrome tab, it needs somewhere to stash references to DOM elements so that a WebDriver command like “click the element I found earlier” can resolve back to the right node. ChromeDriver keeps that cache as a property on the page’s document object. The property has a fixed, recognizable name, hard-coded in the driver’s injected JavaScript: $cdc_asdjflasutopfhvcZLmcfl_. The string looks like keyboard mashing because it was meant to be unguessable, not invisible. The code lives in ChromeDriver’s call_function.js inside the Chromium tree, where the cache key is defined once and reused for every element lookup.
The trouble with a fixed name on the document object is that anything running in the page can read it. A site does not need privileged access or a fingerprinting SDK to check; a single property access answers the question. If document['$cdc_asdjflasutopfhvcZLmcfl_'] is defined, the page is being driven by ChromeDriver. The property has been present since at least ChromeDriver 75, and detection scripts have keyed on both $cdc_ and the older $wdc_ markers for years. It is the cheapest possible Selenium tell, which is exactly why anti-bot vendors checked it first and why a tool aimed at defeating those vendors had to start there.
There is a subtlety in why the name is fixed rather than randomized per session, and it matters for understanding the patch that came later. The cache has to survive a page that overrides built-in globals. A hostile page can reassign window.Array or window.Promise or window.Symbol out from under the driver, and ChromeDriver still needs to find its cache to function. Pinning the cache to a known string on document is how the driver guarantees it can always reach its own state. The property exists for the driver’s correctness, not as an oversight. That is the recurring tension in this whole space: the tells are usually load-bearing, which is why removing them cleanly is hard.
Patching the binary: rename, then prevent
The first generation of evasion did the obvious thing. If the tell is a fixed string baked into the chromedriver executable, edit the executable and change the string. The community technique, predating undetected-chromedriver and packaged in small tools like ldstr’s BasicChromeDriverDetectionPatch, was to open the chromedriver binary, find the cdc_ substring, and overwrite it with a replacement of the same byte length so the offsets in the binary do not shift. Change cdc to three other characters and the document property gets a different name; the detection script keyed on the literal $cdc_asdjflasutopfhvcZLmcfl_ no longer matches.
undetected-chromedriver, written under the handle ultrafunkamsterdam, automated that patch and wrapped it in a zero-config package. Its patcher.py locates the injected block in the binary with a byte regex and rewrites it in place. The current patcher does not bother generating a plausible random replacement name; it matches the window.cdc block with the pattern \{window\.cdc.*?;\} and overwrites it with a fixed string, padded out with spaces to preserve the original block length. A gen_random_cdc helper that builds a 27-character random string from ASCII letters exists in the source as scaffolding but is not what the live path uses. The point of the patch is only that the literal the page would test for is gone; what replaces it does not need to be subtle, because no honest page checks for console.log("undetected chromedriver 1337!").
That approach has two structural problems, and both eventually bit. The first is that editing a signed executable trips antivirus heuristics; a freshly patched chromedriver on Windows can get quarantined on first run because a self-modifying binary that rewrites its own internals looks exactly like malware behavior. The second is deeper. Renaming the property defeats a script that tests one literal string, but it does nothing against a script that enumerates document looking for any property matching a pattern, or one that checks for the structural fact that some non-standard cache object is hanging off document at all. A rename is a fix for an exact-match check, and exact-match checks are the easiest kind to upgrade.
The project’s answer, in the 3.x line, was to stop renaming and start preventing. The README describes the rewrite plainly: rather than removing and renaming the variables, the newer approach keeps them out of the page entirely, preventing them from being injected in the first place. If the cache never lands on the page’s document, there is no property to find under any name. That is a strictly better posture than rename-and-hope, and it is the version of the tool most people ran for years. It also moved the project’s whole evasion stance from “disguise the artifact” to “do not produce the artifact,” which is the same direction the successor would later take to its logical end.
navigator.webdriver and the launch flags
The cdc_ property is a ChromeDriver artifact. There is a second, entirely separate tell that comes from the browser itself, and any Selenium-based tool had to handle it too. navigator.webdriver is a boolean defined by the W3C WebDriver specification, and its whole job is to advertise automation. When the browser is under WebDriver control it returns true; in an ordinary hand-driven session it reads false. The spec is explicit about the conditions: in Chrome the property goes true when the browser is launched with --enable-automation, or with --headless, or with --remote-debugging-port set to port 0. This is not a leak or a bug. It is a standardized signal that the browser is supposed to expose, and a site is well within spec to read it.
Because the value is set at launch via Blink’s automation-controlled feature, the clean way to suppress it is also at launch. Starting Chrome with --disable-blink-features=AutomationControlled turns off the Blink feature responsible for setting the flag, and navigator.webdriver reads false again. undetected-chromedriver applies that flag along with stripping the enable-automation switch and the automation extension that stock Selenium adds. The alternative, overriding the property from JavaScript after the fact with a defined getter, is weaker, because the override is itself detectable: a property that should be a plain data slot on the prototype showing up as an own-property getter on the instance is its own tell, the same trap the puppeteer-extra stealth plugin keeps falling into on the JavaScript side. Killing the flag at the source avoids leaving that second-order trace.
By the time both of these were handled, plus a long tail of smaller property fixes, the tool had a real footprint advantage over plain Selenium. The catch is that all of this work lives above the protocol. The cdc_ cache, the webdriver flag, the launch switches are all things ChromeDriver or Blink expose to the page. None of them touch how the controller talks to the browser. And that is where the next round of detection went.
nodriver: drop the binary, drop Selenium
The successor reframes the whole problem. nodriver, from the same author, is described in its own README as the official successor to undetected-chromedriver, and its headline claim is what it removes: no chromedriver binary, no Selenium dependency. Where the old tool drove Chrome through ChromeDriver, which drove Chrome through CDP, nodriver deletes the middle layer and speaks CDP to the browser directly. It is fully asynchronous, where the predecessor was synchronous Selenium, and the project argues that talking straight to the browser both speeds things up and reduces the detection surface, because the chromedriver intermediary is exactly the thing that was stamping the page with cdc_ artifacts in the first place.
Look at what that move kills for free. If there is no ChromeDriver process, there is no call_function.js, no element cache on document, no $cdc_ property under any name, patched or not. The entire first section of this post becomes moot; you cannot detect an artifact that the architecture never produces. The navigator.webdriver flag still needs handling at launch, because that comes from Blink rather than from the driver, but the largest and most specifically-named Selenium tell simply ceases to exist when you stop running Selenium. nodriver also manages a fresh browser profile per run and cleans it up on exit, and exposes the full set of CDP domains rather than the subset WebDriver wraps, which is what lets it do things like script the Cloudflare interstitial directly.
For a stretch this paid off measurably. In a 2026 anti-detect benchmark that ran seven stealth tools against 31 Cloudflare-protected targets across 651 verdicts, nodriver came out the only tool with zero blocked cells, posting 28 OK, 3 gated, and 0 blocked, ahead of curl_cffi, Patchright, Camoufox, and both vanilla and rebrowser Playwright. The benchmark author’s read on why is worth quoting in substance: nodriver wins because it drives Chrome over CDP directly with no Playwright shim, so the CDP handshake sequence no longer carries Playwright’s fingerprint. The advantage here is the absence of an automation framework’s characteristic protocol behavior rather than any positive stealth trick, which is a different and more durable thing than spoofing a property.
But notice what “the CDP handshake no longer carries Playwright’s fingerprint” implies. It implies the handshake still carries a fingerprint, just a less-recognized one. nodriver removed the chromedriver layer and the Selenium layer. What it did not and cannot remove is CDP itself, because CDP is how it drives the browser. The successor inherits the predecessor’s deepest exposure intact.
The layer underneath: Runtime.enable and the console leak
Every one of these tools, undetected-chromedriver through ChromeDriver and nodriver directly, controls the browser over the Chrome DevTools Protocol. To run JavaScript in a page, a controller needs the page’s execution context, and the standard way to learn those context IDs is to enable the Runtime domain. The CDP command Runtime.enable switches on Runtime. events, including the Runtime.executionContextCreated events that hand the controller the ExecutionContextId it needs for nearly every script-injection operation. Puppeteer, Playwright, and Selenium all call it. It is close to unavoidable if you want to evaluate code in the page the normal way. The protocol itself, and what attaching a client changes inside the browser, is its own detection vector.
The problem is that turning on the Runtime domain changes the browser’s behavior in a way the page can feel. With Runtime.enable active, any call to a console method causes the V8 inspector to build a rich preview of each argument for the DevTools panel that might be listening. Building that preview means walking the object’s properties. A page can hand console an object rigged so that the act of previewing it trips a trap the page controls, and the trap only fires when something on the protocol side is doing the preview. No DevTools window, no automation: no preview, no trip. The technique was used quietly by anti-bot vendors for a couple of years before it became widely understood, and the moment it went public was a DataDome write-up by Antoine Vastel on June 13, 2024 explaining exactly how the console-based signal exposes a CDP-controlled browser.
The mechanics are worth getting right because the patch war that followed turns on the details. One early variant put a getter on an Error object’s stack property and called console.debug on it; with the Runtime domain enabled, the inspector read .stack while formatting the error and the user-defined getter ran, flipping a flag. A more durable variant, documented in 2025, avoids relying on the error-formatting path. It places a Proxy with an ownKeys trap in an object’s prototype chain rather than making the object itself a Proxy, then calls a console method on it. When the inspector builds its preview it walks the prototype chain and must, per the ECMAScript specification’s rules for retrieving a proxy’s own keys, invoke the ownKeys trap. The trap fires synchronously and deterministically. The relevant inspector code lives in V8’s value-mirror.cc and the property iterator in debug-property-iterator.cc, and the construction is deliberate: the immediate object is not a Proxy, so checks that only test the top-level value miss it.
This is the signal nodriver was already designed around, and it is the real reason it scores well. Because it speaks raw CDP rather than going through Playwright’s or Selenium’s connection bootstrap, it can avoid the naive Runtime.enable-on-every-frame pattern and acquire execution contexts by other means. The patch ecosystem that grew up around Playwright and Puppeteer, notably rebrowser-patches, attacks the same problem from the framework side: disable the automatic Runtime.enable, and instead create a binding or an isolated world via Page.createIsolatedWorld to get a usable context without lighting up the console-preview path. Different code, same realization. The fix for the deepest tell is to not enable the domain that produces it. The Runtime.enable leak and the V8 patch war around it have their own history worth reading in full.
The V8 patch war and a Chrome bug that caught everyone
Two things complicate the tidy story that nodriver simply solved the CDP leak. The first is that the leak is a moving target inside V8 itself, defended and re-opened on a cycle measured in weeks. When the error-stack-getter variant of the console trick became well known, the V8 team shipped commits in May 2025, dated around the 7th and the 9th, to stop error previews from running user-defined getters. The fix added a guard that checks whether a getter is user code with a valid ScriptId and skips it during error inspection. That closed the classic signal for the bots that were still caught by it. The same researchers who documented the fix also noted it was incomplete: if a property-descriptor lookup fails on the instance, the inspector falls back to a path that calls the getter directly and bypasses the guard. The prototype-chain ownKeys variant was never touched by those commits at all. So the “fix” narrowed one entry to a structural side channel while leaving the broader one open, which is the normal texture of this fight rather than an exception to it.
The second complication is more pointed, because it caught every CDP-driven tool at once regardless of how carefully it managed Runtime.enable. Around February 2025, Cloudflare deployed a bot check built on a long-standing Chrome bug in how CDP synthesizes input. When a mouse click is dispatched through the protocol onto a target inside an iframe, Chrome computed the click coordinates relative to the iframe rather than the main frame, so CDP-driven clicks on a Turnstile widget landed with coordinates the page could recognize as too small to be a real click in main-frame space. A human clicking the same widget produces main-frame coordinates; a CDP click produced iframe-relative ones. The check keyed on that discrepancy. It hit Puppeteer, Playwright, Patchright, Selenium, and nodriver alike, because the tell was in Chrome’s protocol-input implementation, not in any one library’s behavior. Only non-Chromium or non-CDP approaches sidestepped it. The bug had existed since 2023; Google moved to fix it in the second half of 2025.
Sit with what that episode demonstrates. nodriver had thrown out the chromedriver binary and Selenium, it managed the Runtime domain carefully, it won benchmarks, and a single Chrome input bug that had nothing to do with any of its design choices put it in the same bucket as the tools it was built to outperform. The detection did not care about the architecture above CDP. It read a quirk of CDP itself. When the shared substrate has a tell, every tool on the substrate inherits it, and no amount of cleverness in the wrapper above changes that. This is the structural reason the cycle never ends: the things these tools share are larger than the things they patch.
Why the patch surface keeps moving
There is a pattern across the whole lineage, and it is not an accident of any one tool’s quality. The artifacts that get patched are usually load-bearing. The cdc_ cache exists so ChromeDriver can find its state after a hostile page rewrites the globals. navigator.webdriver exists because a W3C spec says automated browsers must advertise themselves. Runtime.enable gets called because there is no convenient other way to learn a frame’s execution context. Each tell is the visible edge of a feature that something genuinely needs, which is why removing it ranges from awkward to architecturally expensive, and why the successor’s best moves were deletions rather than disguises. You beat the cdc_ tell properly by not running ChromeDriver. You beat the Playwright-handshake tell by not running Playwright, which is part of why patching properties never closes the gap to real Chrome. The wins come from removing a layer, and there are only so many layers to remove before you hit the protocol you cannot give up.
Below that floor, detection has the better position, because the defender only needs the controller and the human to differ on one observable thing, and the controller and the human differ on a great many things by construction. A CDP client builds previews a hand-driven tab never builds. It synthesizes input through a code path with its own arithmetic quirks. It enables domains that flip browser state. Each of those is a candidate signal, and patching one does not reduce the count of the others, it just hides the one that was loudest this quarter. The tools in this lineage are good engineering, and the move from a patched binary to a binary-free CDP client was the right architectural call. It still leaves them standing on the one layer they were built to stand on, and that layer keeps being read by people whose job is to read it. The most recent thing that caught nodriver was not a fingerprint anyone spoofed wrong. It was Chrome miscomputing a coordinate, which is to say it was the floor itself, and you cannot patch the floor from a room built on top of it.
Sources & further reading
- ultrafunkamsterdam (2024), undetected-chromedriver — the original project README, describing the shift from renaming
cdc_variables to preventing their injection, and the explicit note that it does not hide your IP. - ultrafunkamsterdam (2024), nodriver — the successor’s README claiming no chromedriver binary and no Selenium dependency, an async CDP-direct architecture, and a migration utility from undetected_chromedriver.
- Chromium project, call_function.js — the ChromeDriver source where the element-cache key is defined and reused on the document object.
- tonetheman (2019), What is the cdc variable in chromedriver? — names the
$cdc_asdjflasutopfhvcZLmcfl_document property and the one-line script that detects it. - W3C, Navigator: webdriver property (MDN) — documents the WebDriver-spec flag and the exact Chrome launch conditions that set it true.
- Rebrowser (2024), How to fix Runtime.Enable CDP detection of Puppeteer, Playwright and other automation libraries — the clearest write-up of the
Runtime.enableconsole leak and the isolated-world and enable/disable mitigations. - rebrowser (2024), rebrowser-patches — patch collection that disables automatic
Runtime.enableand acquires contexts via bindings orPage.createIsolatedWorld. - Castle (2025), Why a classic CDP bot detection signal suddenly stopped working (and nobody noticed) — traces the May 2025 V8 commits that stopped error previews from running user getters.
- svebaa (2025), How V8 Leaks Your Headless Browser’s Identity — the prototype-chain
ownKeysvariant and the V8 inspector code paths invalue-mirror.ccanddebug-property-iterator.cc. - The Web Scraping Club (2023), Can Undetected Chromedriver bypass Cloudflare or Datadome? — hands-on testing showing local success and datacenter failure against both vendors.
- Ian L. Paterson (2026), Anti-detect browser benchmark 2026 — 7 tools, 31 Cloudflare targets, 651 verdicts; nodriver the only zero-blocked tool.
- Web Scraper (2025), Google patches 100% precise Cloudflare Turnstile bot check — the iframe-relative CDP click-coordinate bug deployed by Cloudflare in February 2025 that caught every CDP-driven tool.
Further reading
How puppeteer-extra-plugin-stealth works, patch by patch
A walkthrough of the individual evasions in puppeteer-extra-plugin-stealth: the webdriver flag, chrome.runtime, the permissions contradiction, plugins and mimeTypes, the WebGL vendor, and iframe.contentWindow, with what each patch fixes and where it leaks.
·21 min readWhy stealth plugins lose: the gap between patching properties and matching real Chrome
Why property-patching stealth is a losing game: detectors test for the patch itself, for consistency across surfaces, and for signals the plugin never touches. Traced through the toString leak, the CDP Runtime.enable signal, and cross-signal consistency checks.
·22 min readThe lifecycle of a stealth patch: discovery, fix, detection, and re-discovery
Traces how a single browser-automation stealth patch moves through its life: a signal is found, the patch hides it, the patch itself becomes a fingerprint, and a new signal replaces the old one. With real examples and the economics of the treadmill.
·19 min read