CDP Detection in 2026: What Changed After the Runtime.enable Fix

The Error.stack getter that exposed Puppeteer and Playwright stopped firing in Chrome 138. Here is what V8 changed, which CDP and non-CDP signals still work in 2026, and a probe page to test your own stack.

CDP Detection in 2026: What Changed After the Runtime.enable Fix
In this article

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:

  1. Puppeteer, Playwright and ChromeDriver call Runtime.enable because they need Runtime.executionContextCreated events to know where to run scripts. The protocol docs describe it as enabling "reporting of execution contexts creation".
  2. Once the domain is enabled, every console.* call produces a Runtime.consoleAPICalled event, and V8 builds a preview of each argument for it.
  3. For an Error, building the preview meant reading .stack (along with .name and .message). The page had replaced .stack with a getter, so the getter ran and flipped the flag.
  4. 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.
  • sourceURL leaks: 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.querySelector in the page's own world, a page that has wrapped that function sees the call.
  • exposeFunction / bindings: page.exposeFunction() and Playwright's window.__playwright__binding__ install globals a page can enumerate. Under the hood they use Runtime.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=new in Chrome 112, and from Chrome 132 the old mode lives only in the separate chrome-headless-shell binary. 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.webdriver left 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 canPlayType for 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

SignalStill works in 2026?What defeats itCost of defeating it
Error.stack getter on console (classic)No on Chrome 138+ (V8 13.8)Up-to-date ChromeNone
Other inspector-preview side effects (e.g. prototype-chain Proxy)Reported working as of Mar 2026; also fires for real users with DevTools openNot calling Runtime.enable (Patchright, nodriver, rebrowser-patches) or not using CDP (Camoufox)Low to medium: driver patch, lost console API
__pwInitScripts, bindings, pptr: sourceURLYes, against stock frameworksPatched drivers; avoid exposeFunctionLow
Main-world execution hooksYesIsolated-world evaluationMedium: no direct access to page globals
JS-patched properties (toString, descriptors)YesDon't patch; use real Chrome or C++-level spoofingMedium to high
Window vs worker mismatchYesSame as above; patches must cover workersHigh if you patch in JS
Headless shell / Chrome for Testing / codecsYesBranded Chrome, headful or new headless, virtual displayMedium: more RAM, Xvfb on servers
CDP input: screenX == pageX, no coalesced eventsLargely fixed in current ChromeCurrent ChromeNone
Behaviour and timingYes, and a growing share of scoringRealistic pacing, fewer requests per identityHigh: throughput
TLS / HTTP/2 / IP reputationYesReal browser network stack, clean residential or ISP IPsOngoing 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's dummyFn or 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.

Frequently asked questions

Does the Runtime.enable detection still work?

The classic version, a getter on Error.stack logged via console, stopped working in Chrome 138 after two May 2025 V8 inspector commits. An independent March 2026 analysis reported other preview side effects, such as a Proxy on the prototype chain, that still reveal an enabled Runtime domain. Those also fire for real users with DevTools open.

How do websites detect Puppeteer and Playwright?

Mostly through artefacts and environment rather than one CDP trick: globals like __pwInitScripts and exposed bindings, Puppeteer's pptr: sourceURL, main-world function hooks, headless-shell and Chrome for Testing traits, JavaScript-patched properties, worker mismatches, plus behaviour, TLS/HTTP2 fingerprints and IP reputation.

What Chrome version fixed the CDP console detection?

The fix landed in V8 13.8 via commits 61a90754 (7 May 2025) and e08e9734 (9 May 2025), which stop the inspector from running user getters during object and error previews. V8 13.8 shipped in Chrome 138, which reached stable on 24 June 2025.

Is Camoufox detectable through CDP?

Not through CDP: Camoufox is a Firefox fork controlled by Playwright over Juggler, and Firefox removed its CDP implementation in version 141. Camoufox can still be detected in other ways, including fingerprint inconsistencies and Firefox-specific engine checks.

How can I test if my headless browser is detected?

Run a local probe page with the known checks under each driver and compare against a normal Chrome window, with and without DevTools open. Then use public pages such as the rebrowser bot detector, BrowserScan's bot detection page, CreepJS and Device and Browser Info's 'Are you a bot?' test, through the same proxy you use in production.

Test your proxies against real anti-bot defenses

Free proxy checker — latency, anonymity and block signals in one click.

Run a free check
← Back to Blog