The best-known CDP detection trick, a getter on Error.stack that fires when an automation client has called Runtime.enable, stopped working once two V8 inspector commits from May 2025 shipped in Chrome 138. That didn't make CDP-driven browsers invisible. Researchers have since found other ways to observe an enabled Runtime domain, and most commercial detection now relies on framework artefacts, headless and environment tells, input properties, and behaviour and network signals that never depended on that one check.
What CDP detection is and why every major framework is exposed
CDP detection means spotting that a browser is being driven over the Chrome DevTools Protocol rather than by a person. It works because the protocol was built for debugging, and some debugger features leave traces that page JavaScript can observe.
The Chrome DevTools Protocol is the WebSocket API the DevTools window itself uses: domains such as Runtime, Page, Network and Input, each with commands and events. Most Chromium automation goes through it:
- Puppeteer controls Chrome over CDP. For Firefox it now uses WebDriver BiDi.
- Playwright uses CDP for Chromium. For Firefox it uses Juggler, a separate protocol inside Firefox.
- Selenium 4 talks WebDriver to ChromeDriver, which drives Chrome over CDP internally, and Selenium also exposes CDP commands directly while it moves to BiDi.
Firefox has since left CDP behind. Mozilla deprecated its CDP implementation in Firefox 129 and removed it completely in Firefox 141. "CDP detection" in 2026 therefore means detecting automated Chromium-family browsers.
The reason this signal class appealed to defenders is the one DataDome gave in its June 2024 research (republished from its threat-research blog): "Being able to detect that a browser is instrumented with CDP is key to detect most modern bot frameworks." Once new headless Chrome shared its codebase with regular Chrome, most fingerprint inconsistencies disappeared. A protocol-level side effect would catch Puppeteer, Playwright, Selenium and any unknown framework built on the same plumbing.
How the classic Runtime.enable check worked
The check abused the fact that when the Runtime domain is enabled, V8's inspector serialises every console argument so it can send a preview to the client. Serialising an object reads its properties, and reading a property with a getter runs page code.
The version Castle documented looked like this:
let wasAccessed = false;
const e = new window.Error();
Object.defineProperty(e, 'stack', {
configurable: false,
enumerable: false,
get: function () {
wasAccessed = true;
return '';
}
});
console.debug(e);
// wasAccessed === true => something is listening on the Runtime domain
Step by step:
- Puppeteer, Playwright and ChromeDriver call
Runtime.enablebecause they needRuntime.executionContextCreatedevents to know where to run scripts. The protocol docs describe it as enabling "reporting of execution contexts creation". - Once the domain is enabled, every
console.*call produces aRuntime.consoleAPICalledevent, and V8 builds a preview of each argument for it. - For an
Error, building the preview meant reading.stack(along with.nameand.message). The page had replaced.stackwith a getter, so the getter ran and flipped the flag. - In an ordinary browser with nothing attached, no preview is built and the getter never runs.
It took a few lines of JavaScript, cost almost nothing, and caught every framework that called Runtime.enable, whatever it did to navigator.webdriver or the user agent. Stealth plugins that patch JavaScript properties couldn't fix it, because the leak sat in the protocol, not the page. The countermeasure that emerged was at the driver level: rebrowser-patches and Patchright avoid calling Runtime.enable, and DataDome noted in 2024 that nodriver and Selenium Driverless used "low-level CDP commands that do not leverage Runtime.enable".
What V8 changed in 2025
V8 closed the side effect in two commits in May 2025: the inspector stopped running user-defined getters when it previews objects and errors. The fix landed in V8 13.8, which shipped in Chrome 138 (stable on 24 June 2025).
61a90754(7 May 2025), "[inspector] Prevent side effects during object preview", which avoids "running user code when enabling runtime domain".e08e9734(9 May 2025), "[inspector] Prevent side effects during error preview", which changes error description generation "to avoid running getters with side effects".
Both commits reference Chromium bug 415094795. The first was written by Pavel Feldman, a Playwright maintainer at Microsoft, so this came from the automation-tooling side, not from a stealth project. Castle's Antoine Vastel wrote it up on 28 August 2025 under the title "Why a classic CDP bot detection signal suddenly stopped working (and nobody noticed)", pointing to a new getErrorProperty() helper that skips user getters during preview. On Chrome 138 and later, the snippet above returns false even under stock Puppeteer.
Is Runtime.enable still detectable?
Probably, though less reliably than before. In March 2026 an independent researcher, Sveba, published an analysis of V8's preview path showing two remaining routes. One is a code path the May 2025 guard misses when GetOwnPropertyDescriptor fails, which the author says they "verified … is exploitable in post-patch Chrome". The other uses a Proxy placed on an object's prototype chain:
let detected = false;
const trap = new Proxy({}, {
ownKeys() { detected = true; return []; }
});
const obj = Object.create(trap);
console.groupEnd(obj); // any console method works
// detected === true => inspector is building previews
According to the write-up, the inspector checks only whether the value itself is a Proxy, not its prototypes, and collects all keys eagerly. Enumerating the chain therefore triggers the ownKeys trap. The author tested this on a content_shell build from March 2026 and reported it unpatched at the time. We haven't confirmed it against every stable Chrome release, so treat it as reported, not universal.
Two caveats limit how far a defender can lean on any Runtime.enable signal now. First, a real user with DevTools open goes through the same code path, so the signal says "an inspector is attached", not "this is a bot". Second, the frameworks most worth catching already avoid Runtime.enable, so it mostly flags unmodified Puppeteer, Playwright and Selenium. The industry has moved toward stacking many weaker signals, which Castle's post also says.
Signals that still work in 2026
The signals that hold up now come from what the framework does in the page, how the browser was built and launched, how input arrives, and how the session behaves on the network. Hiding a single CDP side effect doesn't remove any of these.
Framework artefacts in the main world
Stock frameworks leave fingerprints of their own. The rebrowser bot detector lists several:
__pwInitScripts: Playwright adds it to the global scope of every page by default.sourceURLleaks: Puppeteer tags evaluated scripts with//# sourceURL=pptr:…, which can show up in an error stack captured inside a hooked function.- Main-world execution: if your script calls
document.querySelectorin the page's own world, a page that has wrapped that function sees the call. exposeFunction/ bindings:page.exposeFunction()and Playwright'swindow.__playwright__binding__install globals a page can enumerate. Under the hood they useRuntime.addBinding, which adds a function "on the global objects of all inspected contexts".- Utility world names: frameworks create isolated worlds with recognisable names, which rebrowser-patches lets you rename.
Init-script artefacts
Page.addScriptToEvaluateOnNewDocument runs a script "in every frame upon creation (before loading frame's scripts)". It's how stealth plugins patch navigator before the site's code runs. The protocol doesn't leak, but the patches do. As the Camoufox README puts it, injected JavaScript can be caught via Object.getOwnPropertyDescriptor, by a function's toString() no longer returning [native code], or by values that differ between the window and a worker.
Worker contexts
Workers are where patching most often fails. Init scripts and property overrides applied to the page don't automatically reach dedicated, shared or service workers. A worker that reports the real navigator.userAgent, hardwareConcurrency or WebGL renderer while the window reports spoofed values is a strong tell. CreepJS checks worker scope specifically for this reason.
Headless and build tells
- Headless shell vs new headless. Chrome launched
--headless=newin Chrome 112, and from Chrome 132 the old mode lives only in the separatechrome-headless-shellbinary. Playwright uses chromium headless shell by default when headless, and it behaves differently from real Chrome. - Build identity. A "Chrome for Testing" user agent, default viewports (800×600 in Puppeteer, 1280×720 in Playwright), and
navigator.webdriverleft on are all cheap checks the rebrowser detector lists. - Codecs. Open-source Chromium builds don't include the proprietary codecs Google Chrome ships, such as H.264 and AAC, according to the Chromium project. A browser that claims to be Chrome but fails
canPlayTypefor H.264 is really Chromium. - Permissions and media devices. In headless and container environments, notification permission states, empty media device lists and missing speech voices often fail to match what the user agent claims. CreepJS and BrowserScan check these.
Synthetic input
Input sent with Input.dispatchMouseEvent produces trusted events (isTrusted is true), but it has had properties real input doesn't. The best known was screenX/screenY equal to pageX/pageY outside fullscreen. Chromium fixed it in change 6917162, "Fix screen coordinates to avoid automation detection", merged on 15 September 2025, before the Chrome 142 branch point. The CDP-Patches project, which existed to work around this, now says CDP input also emits coalesced events and archived itself. On current Chrome these two specific checks are mostly gone, while trajectory, timing and click-position analysis still apply.
Behaviour and network
None of the signals below involve CDP, and they make up most of what vendors score. Castle lists client-side behavioural analysis, environment "red pills", proxy and network detection, and time-series analysis of coordinated activity. On the network side that includes TLS fingerprints and HTTP/2 fingerprints that must match the claimed browser, plus IP reputation. GPU-level fingerprinting is covered in our canvas and WebGL deep-dive.
How Patchright, nodriver and Camoufox handle CDP leaks
All three avoid Runtime.enable-style leaks, in different ways, and none of them solves behaviour or network reputation.
Patchright is a patched Playwright driver for Chromium only. According to its README, its main patch avoids Runtime.enable "by executing Javascript in (isolated) ExecutionContexts". It also disables the Console domain entirely (so console logging doesn't work), drops --enable-automation, adds --disable-blink-features=AutomationControlled, and recommends running real Chrome (channel="chrome") without fingerprint injection. It follows Playwright releases closely (the v1.63.0 driver shipped in September 2026). Our Patchright deep-dive covers setup.
nodriver, the successor to undetected-chromedriver, removes WebDriver and talks CDP to your installed Chrome. A look through its current core/ source shows it evaluating with Runtime.evaluate and Runtime.callFunctionOn and never calling Runtime.enable, which matches DataDome's 2024 description. The trade-off is that evaluation happens in the main world, where a page that wraps DOM functions can see it, and there is no fingerprint management. The last PyPI release is 0.50.3 from May 2026. See our nodriver guide.
Camoufox sidesteps CDP entirely. It's a Firefox fork, and Playwright drives Firefox through Juggler, which Camoufox patches so Playwright works on an isolated copy of the page: page scripts can't see injected helpers or watch for getter reads. Input goes through Firefox's normal input handlers, and fingerprint values are spoofed in C++ rather than by init scripts, which removes the toString and worker-mismatch class. The cost is that it's Firefox. The README says some WAFs test SpiderMonkey engine behaviour, so it can't pass as Chrome.
Signal reference table
| Signal | Still works in 2026? | What defeats it | Cost of defeating it |
|---|---|---|---|
Error.stack getter on console (classic) | No on Chrome 138+ (V8 13.8) | Up-to-date Chrome | None |
| Other inspector-preview side effects (e.g. prototype-chain Proxy) | Reported working as of Mar 2026; also fires for real users with DevTools open | Not calling Runtime.enable (Patchright, nodriver, rebrowser-patches) or not using CDP (Camoufox) | Low to medium: driver patch, lost console API |
__pwInitScripts, bindings, pptr: sourceURL | Yes, against stock frameworks | Patched drivers; avoid exposeFunction | Low |
| Main-world execution hooks | Yes | Isolated-world evaluation | Medium: no direct access to page globals |
JS-patched properties (toString, descriptors) | Yes | Don't patch; use real Chrome or C++-level spoofing | Medium to high |
| Window vs worker mismatch | Yes | Same as above; patches must cover workers | High if you patch in JS |
| Headless shell / Chrome for Testing / codecs | Yes | Branded Chrome, headful or new headless, virtual display | Medium: more RAM, Xvfb on servers |
| CDP input: screenX == pageX, no coalesced events | Largely fixed in current Chrome | Current Chrome | None |
| Behaviour and timing | Yes, and a growing share of scoring | Realistic pacing, fewer requests per identity | High: throughput |
| TLS / HTTP/2 / IP reputation | Yes | Real browser network stack, clean residential or ISP IPs | Ongoing proxy spend |
How to test your own setup
Test against your own probe page first, then against public detection pages, and change one variable at a time. Public pages tell you what they detect. Your own page tells you which Chrome build and driver combination still triggers which check.
1. A local probe page
Save this as probe.html. It runs the classic check, the prototype-chain check and a few cheap artefact checks, then writes the results into the DOM so any driver can read them, whichever world its scripts run in.
<!doctype html>
<pre id="out">running</pre>
<script>
function classic() {
let hit = false;
const e = new Error();
Object.defineProperty(e, 'stack', { configurable: false, enumerable: false,
get() { hit = true; return ''; } });
console.debug(e);
return hit;
}
function protoProxy() {
let hit = false;
const trap = new Proxy({}, { ownKeys() { hit = true; return []; } });
console.groupEnd(Object.create(trap));
return hit;
}
const r = {
classicStackGetter: classic(),
protoProxyPreview: protoProxy(),
webdriver: navigator.webdriver,
pwInitScripts: '__pwInitScripts' in window,
h264: document.createElement('video').canPlayType('video/mp4; codecs="avc1.42E01E"'),
ua: navigator.userAgent,
};
setTimeout(() => { document.getElementById('out').textContent = JSON.stringify(r, null, 2); }, 300);
</script>
Then run it under each setup you care about:
import pathlib
from playwright.sync_api import sync_playwright # swap for: from patchright.sync_api import sync_playwright
url = pathlib.Path("probe.html").resolve().as_uri()
with sync_playwright() as p:
browser = p.chromium.launch(channel="chrome", headless=False)
page = browser.new_page()
page.goto(url)
page.wait_for_function("document.getElementById('out').textContent !== 'running'")
print(page.text_content("#out"))
browser.close()
Run stock Playwright, then Patchright, then the same page in a normal Chrome window with and without DevTools open. The DevTools run is your control: anything that fires there will also fire for real users, so it's a weak signal on its own.
2. Public detection pages
- rebrowser bot detector: Chromium-focused, covering Runtime.enable, sourceURL, main-world execution,
exposeFunction,__pwInitScripts, viewport and user agent. Some tests only fire if your script calls the page'sdummyFnor exposes a function, as the page explains. - BrowserScan bot detection: reports WebDriver, user agent, CDP and navigator-consistency checks.
- CreepJS: open-source fingerprint analysis that flags "lies" (patched properties), headless traits and worker-scope mismatches. It's the best one for catching JavaScript-level spoofing.
- Device and Browser Info "Are you a bot?": fingerprint and behaviour tests, including CDP, WebDriver, Playwright and headless Chrome checks.
Run these through the same proxy you'll use in production. Several of them show IP and timezone alongside browser signals, and a perfect browser score on a datacenter IP tells you little about real traffic. A sticky session (USERNAME-sid-abc123-ttl-10m on the residential gateway) keeps the IP fixed while you compare runs.
Remember that passing a test page isn't the same as passing a site. Commercial vendors use server-side models, cross-session history and signals these pages can't see.
What this does not solve
Closing CDP leaks removes one category of evidence. It doesn't make automation look human, and it doesn't make collection permitted. Rate, sequencing and volume per identity still give automation away, and IP reputation and TLS still have to match the browser you claim to be. Access rules still apply: robots.txt, terms of service, and data-protection law for anything personal. If you run detection, the practical lesson from 2025 is not to depend on a single side effect that a browser vendor can remove in a routine commit. If you run automation, keep your tools current, prefer drivers that don't patch JavaScript, and assume the remaining signals are behavioural.






