Skip to content

nodriver and undetected-chromedriver: what they patch and why it eventually breaks

· 20 min read
Copyright: MIT
Wordmark reading nodriver with an orange arrow striking through the word chromedriver

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.

the cheapest Selenium tell: one property on document ChromeDriver call_function.js injects element cache document $cdc_asdjflasutopfhvcZLmcfl_ = { element references } page script if (document[key]) => bot No SDK needed. A defined property is the whole signal. *ChromeDriver writes its element cache to a fixed-name property on document; any in-page script can read that name back and conclude the tab is automated.*

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.

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.

the surface, from page-visible artifacts down to the protocol document.$cdc_… ChromeDriver element cache — patched in the binary, then not injected navigator.webdriver W3C-spec flag — suppressed with --disable-blink-features=AutomationControlled --enable-automation, automation extension launch switches — stripped at startup Chrome DevTools Protocol Runtime.enable, console previews, CDP input quirks — the layer no rename touches Patches climb the top three rows. Detection drops to the orange one. *The visible tells sit at the top and are individually patchable. The protocol layer at the bottom is shared by every CDP-driven tool, which is where detection moved once the top rows went quiet.*

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.

why a console call leaks the controller Runtime.enable controller wants ctx IDs page calls console.* on a rigged object inspector builds preview ownKeys / getter trap fires page now knows CDP is attached no Runtime domain enabled => no preview built => trap never runs The signal is the side effect of preview serialization, not any property value. *The leak is behavioral. Enabling the Runtime domain makes a console call serialize a preview; a page-controlled trap in that serialization path reveals that a protocol client is attached.*

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

Further reading