AntiDetect
Features Technology Pricing Who's It For
Docs Blog FAQ
Download

Ten Ways the DevTools Protocol Tells On You

Every mainstream automation stack drives Chrome through the DevTools Protocol. CDP was built for debugging, not for hiding — and it leaves a trail in a dozen places. Here is where, and why a JavaScript patch usually makes it worse.

CDP was never meant to be quiet

Puppeteer, Playwright, Selenium 4 and every "stealth" wrapper built on them share one foundation: the Chrome DevTools Protocol. It is the same channel your browser opens when you press F12. Chrome exposes it so that a debugger can inspect and drive the page, and it was designed with exactly one non-goal — hiding the fact that a debugger is attached.

That is the root of the problem. The signals below are not bugs in Puppeteer. Most of them are CDP behaving correctly, in ways a human-driven browser never does. A detection vendor does not need to break anything to find them; it just needs to read values the platform hands out for free.

What follows is the list as it stands in 2026, ordered roughly by how cheap each one is to check.

navigator.webdriver, and why patching it in JS is worse

The oldest signal. navigator.webdriver returns true whenever Chrome is started with automation enabled. Every tutorial tells you to override it:

Object.defineProperty(navigator, 'webdriver', { get: () => false })

This is not a fix. It is a second, louder signal. In a stock browser webdriver is an accessor on Navigator.prototype, not an own property of the instance. After the snippet above, three things are true that are not true anywhere else:

Detection scripts have walked the toString chain for years. A browser that returns the honest true is a browser running automation. A browser that returns a forged false is a browser running automation and trying to hide it, which for most risk engines is the worse of the two.

The only version of this that survives inspection is one where the C++ getter itself returns false, so there is no own property, no arrow function, and nothing on the prototype chain out of place.

Runtime.enable: the leak that rewrote everyone’s stack

To evaluate JavaScript in a page, most libraries call Runtime.enable. That single command has side effects that reach into the page: it starts emitting Runtime.executionContextCreated notifications, and it changes how console arguments are serialised.

The classic exploit is short enough to fit in a tweet. Create an object with a getter, pass it to console.debug, and see whether the getter fires. With a debugger attached and Runtime.enable called, the protocol serialises the object for the client and the getter runs. Without one, nothing touches it.

let leaked = false;
const probe = { get id() { leaked = true; return 1; } };
console.debug(probe);
// leaked === true  →  something is listening on CDP

This is what pushed the ecosystem toward patched forks — rebrowser-puppeteer and friends — that avoid Runtime.enable and use isolated worlds or addBinding instead. Those patches work, and they are worth using. They also close exactly one hole out of ten.

Three bugs in synthetic input

When automation moves a mouse, it does not move a mouse. It calls Input.dispatchMouseEvent, and Chrome constructs an event object that is very nearly, but not quite, what the real input pipeline produces. Three differences are trivially observable from a page.

screenX and screenY are wrong

This is Chromium bug 40280325. CDP sets screenX = clientX and screenY = clientY — viewport coordinates where screen coordinates belong. For a real click the two differ by the window position plus the browser chrome: tab strip, address bar, bookmarks. On a maximised window that is roughly 90 to 130 pixels of vertical offset.

So a genuine click near the top of a page has screenY around 150. A CDP click at the same place reports screenY = 20. Cloudflare Turnstile treats a low screenY as a bot click, and it is right to: no human can click above their own browser chrome.

UIEvent.detail is zero

For a real click, event.detail carries the click count — 1 for a single click, 2 for a double. CDP-dispatched click events arrive with click_count = 0, so detail is 0. There is no user gesture that produces a click with a detail of zero. Turnstile checks this inside its challenge iframe.

getCoalescedEvents() is empty

Modern browsers batch high-frequency pointer movement and expose the raw samples through PointerEvent.getCoalescedEvents(). A real pointermove always carries at least one coalesced event — itself. A CDP-dispatched pointermove carries an empty array, because there was never a hardware sample to coalesce.

None of these can be repaired from JavaScript. The event object is constructed in C++ before any page script can see it, and the properties are read-only.

Browser window in cross-section showing the offset between clientY and screenY
Chromium bug 40280325. A real click carries the browser chrome in its screen coordinate; a CDP click reports the viewport coordinate instead. Turnstile reads the difference.

The strings automation leaves lying around

Two families of literal strings end up where a page can read them.

ChromeDriver markers. ChromeDriver tracks its own call results by defining document properties prefixed cdc_, $cdc_ or $chrome_. They are not hidden. A three-line loop over Object.getOwnPropertyNames(document) finds them, and detection scripts have looked for exactly this since 2018.

Puppeteer source URLs. Puppeteer appends //# sourceURL=pptr:... to the scripts it evaluates, and creates an isolated world named __puppeteer_utility_world__. Both surface in Error.stack if an exception crosses the boundary:

try { null.f() } catch (e) { /* e.stack may contain "pptr:" */ }

These are the easiest signals to remove and the ones most commonly left in place, because they only appear under conditions a developer rarely tests — an uncaught error inside an evaluated function.

Workers: the context nobody covers

This is the gap that catches the most otherwise-careful setups.

Web Workers, Service Workers and Shared Workers each get their own global scope with their own navigator. That object is created by the engine, in the worker thread, from the browser's real values. CDP page-level overrides — Emulation.setUserAgentOverride, injected scripts, Page.addScriptToEvaluateOnNewDocument — do not reach it.

The result is a browser that claims one thing on the main thread and another inside a worker. A detection script only has to ask twice:

// main thread says 8; worker says 24
new Worker(URL.createObjectURL(new Blob([
  'postMessage([navigator.hardwareConcurrency, navigator.deviceMemory, navigator.platform])'
], { type: 'text/javascript' })));

Any disagreement between the two is conclusive. Real browsers cannot produce one, because both scopes read the same underlying values. The signals that leak this way include hardwareConcurrency, deviceMemory, platform, the full userAgentData brand list, and — through OffscreenCanvas — the WebGL vendor and renderer.

Fixing this from the outside is not possible in any complete way. The values have to come from the engine, which means the worker's navigator has to be built from the same profile as the window's.

The small stuff that still adds up

Three more, each cheap to check and each capable of ending a session on its own.

Outer dimensions with DevTools docked

The difference between outerWidth and innerWidth is the browser chrome — about 16 pixels horizontally and 85 to 90 vertically on desktop Chrome. Dock DevTools and that gap jumps by hundreds of pixels. A window reporting outerHeight - innerHeight = 400 is a window with a debugger open.

Storage quota

navigator.storage.estimate() returns a quota derived from free disk space. It is a strong hardware signal on its own, and it also gives away containers and incognito contexts, which report characteristic round numbers far below any real disk.

Missing APIs for the platform you claim

If your user agent says Windows Chrome 147, the page can check for the APIs Windows Chrome 147 actually ships. Headless builds historically lacked chrome.runtime, the full Notification permission flow, and codec support that headful builds have. Claiming a platform you cannot back up is worse than claiming nothing.

Why the fix has to be in the source

Look at the list and a pattern emerges. Almost every signal originates below JavaScript:

A content script runs after all of it. It can only overwrite what has already been decided, and every overwrite leaves the tells described above: own properties where prototypes belong, arrow functions where native code belongs, disagreement between contexts.

This is the honest argument for a modified Chromium build, and it is worth stating its limits too. Patching the engine fixes the layer below JavaScript. It does not fix TLS and HTTP/2 fingerprints, which sit below the browser entirely, and it does nothing for behaviour — a session that clicks with inhuman regularity will be flagged regardless of how clean its properties are.

Testing your own setup

Do not take a vendor's word for this, including ours. The checks are public and you can run them in an afternoon:

Run them against whatever you use today before you change anything. A tool that passes nine of these and fails the tenth is not nine tenths of the way there — a single conclusive mismatch is enough.

Fixed in the engine, not in a content script

P8 ships a Chromium build where these behave the way an ordinary browser behaves — because the C++ was changed, not wrapped.

Start for Free
All articles Next: Browser Fingerprinting Explained