Dual Login
Automation

Automate Chrome with Puppeteer Over CDP, Undetected

Dual Login Team·2026-05-22·17 min read

How to automate Chrome over CDP with Puppeteer or Python without tripping bot detection — attach to a running fingerprinted profile instead of launching one.

If you want to automate Chrome over CDP and not get flagged, the single most important decision happens before you write a line of code: whether you launch a browser from your script or attach to one that is already running. Almost every detectable artifact people blame on Puppeteer — navigator.webdriver, the automation infobar, the headless giveaways, the odd Runtime behaviour — comes from launching a fresh, instrumented instance with automation flags. Attaching to an already-configured browser over its debug port removes most of them for free.

This is a developer-focused guide to the Chrome DevTools Protocol (CDP): what it is, how its domains are organised, which ones are safe to use, and which one — Runtime — is the fingerprint you most want to avoid leaving behind. We'll cover undetectable browser automation in practice: driving the DOM without ever calling Runtime.enable, dispatching trusted input events, realistic pacing, and working code in both JavaScript and Python. There's also a plain-HTTP path for people who don't want to touch CDP at all.

Nothing here is a magic bypass. Detection is layered — IP reputation, fingerprint consistency, and behaviour all matter, and no automation trick rescues a bad proxy. What this guide does is remove the free signals: the ones you're broadcasting for no benefit.

What the Chrome DevTools Protocol actually is

CDP is the wire protocol that Chrome DevTools itself speaks to the browser. When you open DevTools, you're not using a privileged internal API — you're using a WebSocket client talking JSON to the browser process. Puppeteer, Playwright's Chromium driver, and Selenium's CDP bridge are all clients of the same protocol.

Start Chrome with --remote-debugging-port=9222 and it exposes:

  • http://127.0.0.1:9222/json/version — browser metadata, including webSocketDebuggerUrl, the browser-level WebSocket endpoint.
  • http://127.0.0.1:9222/json/list — every open target (tabs, workers, extension pages), each with its own webSocketDebuggerUrl.
  • A WebSocket per target — where the real work happens.

Messages are simple. You send {"id": 1, "method": "Page.navigate", "params": {"url": "https://example.com"}, "sessionId": "..."} and get back a matching {"id": 1, "result": {...}}. Events arrive unsolicited as {"method": "Page.loadEventFired", "params": {...}}. That's the entire protocol. Puppeteer is a convenience layer over it — a very good one, but it makes choices on your behalf that you may not want.

How domains work

CDP is namespaced into domains, each covering one area of browser functionality. Most domains are stateful: you call Domain.enable to start receiving its events, and Domain.disable to stop. That enable call is not free — it changes browser behaviour and, in at least one case, is directly observable from page JavaScript.

Domain What it does Detection risk
Target Enumerate/attach to tabs, workers, frames; create and close targets Low
Page Navigate, reload, capture screenshots, lifecycle events Low
DOM Query the document tree, resolve nodes, read geometry Low
Input Dispatch trusted mouse, keyboard, and touch events Low
Network Observe/modify requests, read cookies, set headers Low–medium
Emulation Override timezone, geolocation, device metrics, user agent Medium (see below)
Runtime Evaluate JavaScript, expose bindings, mirror objects High

The practical rule: read and act through DOM + Input, observe through Page + Network, and treat Runtime as a last resort.

Why standard Puppeteer and Selenium launches are detectable

When Puppeteer launches Chrome itself, it adds flags and behaviours that a detection script can see. The same is true of a default Selenium/ChromeDriver session. These are the recurring tells.

navigator.webdriver === true. Chrome sets this whenever it's launched under automation control (--enable-automation, or ChromeDriver's default). It's a single boolean, checked by essentially every commercial bot-detection vendor, and patching it from page JS after the fact leaves its own residue — a navigator property with a suspicious descriptor, or a Object.getOwnPropertyDescriptor result that doesn't match a real Chrome.

The automation infobar and switch. "Chrome is being controlled by automated test software" is the visible symptom; the underlying --enable-automation switch also disables some features and alters default behaviours (password manager prompts, certain permission defaults). Detection scripts probe those side effects, not just the banner.

CDP-runtime artifacts. This is the subtle one. Enabling the Runtime domain makes the browser serialise objects passed to console methods so they can be inspected by the debugger. A page can detect this by defining an object with a getter on a property the serialiser touches (classically toString or id on an Error), logging it, and seeing whether the getter fires. If it fires with no DevTools window open, something is attached with Runtime enabled. Several anti-bot libraries have shipped this check for years.

Headless signatures. Old headless Chrome was trivially identifiable (HeadlessChrome in the UA, missing navigator.plugins, no window.chrome, broken permissions API). New headless closed most of that, but headless still differs in GPU/ANGLE strings, font availability, media codec support, and screen metrics. If you're running headless on a Linux VPS while claiming to be Windows Chrome on a 1920×1080 display, the mismatch is the detection — not the automation.

Fingerprint inconsistency generally. A stock Puppeteer instance reports your machine: your GPU, your fonts, your timezone, your canvas hash. Run ten "different accounts" from it and they share one device identity. That's a linkage problem, not a bot problem, and it's the reason antidetect browsers exist at all. Our browser fingerprinting explainer breaks down which surfaces actually carry entropy, and you can check what a given profile leaks with the free fingerprint checker.

Timing patterns. Instant page-load-to-click, exactly 0 ms between keystrokes, perfectly linear mouse paths, and identical dwell times across sessions form a behavioural signature that survives every fingerprint fix. Detection vendors increasingly weight this heavily because it's expensive to fake convincingly.

Attach, don't launch: connecting to an already-running browser

Here's the shift that solves most of the above at once. Instead of asking Puppeteer to start Chrome, you:

  1. Launch a fingerprinted profile through your antidetect browser — real user-data-dir, real proxy, native fingerprint, no --enable-automation.
  2. Read the profile's debug port / WebSocket endpoint.
  3. puppeteer.connect() to it.

The browser was already running as a normal browser before your script showed up. navigator.webdriver is false because it was never launched under automation control. There's no infobar. There's no headless. The fingerprint, proxy, timezone, and storage are whatever the profile says they are.

In Dual Login, the fingerprint is applied natively in the Chromium core rather than injected as JavaScript, which matters here: injected spoofing can be caught by comparing values across contexts, because injection often misses Web Workers and cross-origin iframes. Native application means canvas, WebGL, audio, fonts, screen, user-agent, timezone, languages, and geolocation stay consistent everywhere the page can look. Your automation layer inherits that consistency instead of fighting it.

Connecting with Puppeteer

import puppeteer from 'puppeteer-core';

// The profile is ALREADY running with a debug port open.
// Fetch the browser-level WebSocket endpoint from the DevTools HTTP API.
const res = await fetch('http://127.0.0.1:9222/json/version');
const { webSocketDebuggerUrl } = await res.json();

const browser = await puppeteer.connect({
  browserWSEndpoint: webSocketDebuggerUrl,
  defaultViewport: null,          // never resize the real window
});

const pages = await browser.pages();
const page = pages[0] ?? (await browser.newPage());

await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

// Detach cleanly — do NOT call browser.close(), that kills the profile.
await browser.disconnect();

Two details that people get wrong:

  • defaultViewport: null. Puppeteer's default viewport calls Emulation.setDeviceMetricsOverride, which changes window.outerWidth/innerWidth relationships and can produce a device-metrics profile that doesn't match a real window. Pass null and inherit the actual window.
  • disconnect(), not close(). You're a guest in someone else's browser. Closing it terminates the profile session and, depending on timing, can cut short cookie persistence.

Why avoiding Runtime.enable matters

Puppeteer's ergonomic API is built on Runtime. page.evaluate(), page.$eval(), page.waitForFunction(), element handles — all of them require the Runtime domain, and Puppeteer enables it eagerly on every new page context. That's convenient and it's exactly the artifact described above.

You cannot un-enable it invisibly either: once the browser has started serialising for a debugger, the observable behaviour has already changed for that execution context.

So the discipline for undetectable browser automation is:

  • Never call Runtime.enable.
  • Never use any Puppeteer method that implies it, on pages where detection matters.
  • Do your work through DOM, Input, Page, and Network, which have no equivalent page-visible side effect.

This is a real constraint. You give up arbitrary JS evaluation, which means you give up the easiest way to read computed styles, run custom predicates, and inspect framework state. In exchange you get an automation surface that behaves like a human with a mouse. For most account-management, QA, and data-collection work, that trade is correct. If you genuinely need evaluate() for one step, make it an explicit, logged exception on a low-risk page rather than the default for everything.

Acting on the DOM without Runtime

The Runtime-free loop is always the same four steps: get the document, find the node, measure it, act on the coordinates.

// Raw CDP over the same connection — no Runtime, ever.
const client = await page.createCDPSession();
await client.send('DOM.enable');

async function clickSelector(selector) {
  // 1. Fresh document root. NOTE: this resets the backend node map,
  //    so resolve + act must happen together, never interleaved.
  const { root } = await client.send('DOM.getDocument', { depth: -1 });

  // 2. Resolve the selector to a nodeId.
  const { nodeId } = await client.send('DOM.querySelector', {
    nodeId: root.nodeId,
    selector,
  });
  if (!nodeId) throw new Error(`not found: ${selector}`);

  // 3. Geometry. content quad = [x1,y1, x2,y2, x3,y3, x4,y4]
  const { model } = await client.send('DOM.getBoxModel', { nodeId });
  const [x1, y1, , , x3, y3] = model.content;
  const x = (x1 + x3) / 2;
  const y = (y1 + y3) / 2;

  // 4. Trusted input at those coordinates.
  await client.send('Input.dispatchMouseEvent', {
    type: 'mouseMoved', x, y, buttons: 0,
  });
  await sleep(60 + Math.random() * 120);
  await client.send('Input.dispatchMouseEvent', {
    type: 'mousePressed', x, y, button: 'left', clickCount: 1, buttons: 1,
  });
  await sleep(40 + Math.random() * 70);        // human press duration
  await client.send('Input.dispatchMouseEvent', {
    type: 'mouseReleased', x, y, button: 'left', clickCount: 1, buttons: 0,
  });
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

Typing without Runtime

Two options, and they are not equivalent:

  • Input.insertText — inserts a string as if pasted/composed. Fast, fires input events, but produces no keydown/keyup. Fine for long text bodies; wrong for fields that validate keystroke-by-keystroke or listen for Enter.
  • Input.dispatchKeyEvent — real per-character key events with keyDown, char, keyUp. Slower, but indistinguishable from a keyboard.
async function typeHuman(text) {
  for (const ch of text) {
    await client.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch });
    await client.send('Input.dispatchKeyEvent', { type: 'keyUp', text: ch });
    // 90–220ms with occasional longer pauses ≈ real typing cadence
    const base = 90 + Math.random() * 130;
    await sleep(Math.random() < 0.08 ? base + 400 : base);
  }
}

Waiting without waitForFunction

Poll DOM.querySelector on a fresh document with a backoff. It's less elegant than waitForFunction, but it's the same thing a person does — look, wait, look again.

The node-id trap

DOM.getDocument invalidates previously issued nodeIds. If you resolve a node, await something else, then act on the stale id, you'll get Could not find node with given id intermittently — and it'll look like flaky selectors when it's actually a concurrency bug. Serialise resolve-and-act as one atomic unit per tab. If multiple workers drive the same tab, put a mutex around it.

Trusted versus synthetic events

This distinction decides whether a click counts.

A synthetic event is one created in page JavaScript: element.click(), new MouseEvent('click', {...}) plus dispatchEvent. Its event.isTrusted property is false. Many login forms, payment widgets, consent dialogs, and drag handles explicitly reject untrusted events, and detection scripts log them as a strong bot signal.

A trusted event originates from the browser's input pipeline. Input.dispatchMouseEvent and Input.dispatchKeyEvent enter through that pipeline, so isTrusted is true, focus and hover states update, :active styles apply, and IME/composition behaves normally. This is the core reason CDP Input beats JS-injected clicking, and why coordinate-based automation is worth the extra geometry work.

Practical consequences:

  • Scroll the element into view first (DOM.scrollIntoViewIfNeeded), because coordinates outside the viewport dispatch into nothing.
  • Move the mouse before you press. A press with no preceding mouseMoved at those coordinates is anomalous.
  • Respect overlays. If a cookie banner covers your target, a trusted click hits the banner — exactly like a human would.

Mapping tasks to the right CDP approach

Task Recommended approach Avoid
Navigate to a URL Page.navigate + wait for Page.loadEventFired page.evaluate('location.href=...')
Click a button DOM.querySelectorDOM.getBoxModelInput.dispatchMouseEvent element.click() via Runtime.evaluate
Fill a short field Input.dispatchKeyEvent per character Runtime.evaluate setting .value
Paste a long text body Input.insertText Character loop (needlessly slow)
Read visible text DOM.getOuterHTML and parse server-side page.$eval(el => el.innerText)
Check element exists DOM.querySelector returning nodeId !== 0 waitForFunction
Screenshot Page.captureScreenshot Full-page JS canvas hacks
Read/write cookies Network.getCookies / Network.setCookie document.cookie via Runtime
Capture API traffic Network.enable + responseReceived / getResponseBody Injected fetch monkey-patch
Open a new tab Target.createTarget window.open via Runtime
Click unselectable UI (canvas, images) OCR-driven coordinate click Guessing selectors
Truly needs JS evaluation Runtime.evaluate — flagged, one-off, low-risk page only Making it the default

A Python example

Python has no first-class Puppeteer, but CDP is just JSON over WebSocket, so a thin client is ~40 lines. This is often preferable to Selenium: no driver binary, no version pinning, no automation switches.

import json, random, time, requests
from websocket import create_connection   # pip install websocket-client

DEBUG = "http://127.0.0.1:9222"

def open_tab_socket(url_filter="") -> "WSClient":
    targets = requests.get(f"{DEBUG}/json/list", timeout=5).json()
    page = next(t for t in targets
                if t["type"] == "page" and url_filter in t["url"])
    return WSClient(page["webSocketDebuggerUrl"])

class WSClient:
    def __init__(self, ws_url):
        self.ws = create_connection(ws_url, timeout=30)
        self._id = 0

    def send(self, method, **params):
        self._id += 1
        self.ws.send(json.dumps({"id": self._id,
                                 "method": method,
                                 "params": params}))
        while True:                       # skip unsolicited events
            msg = json.loads(self.ws.recv())
            if msg.get("id") == self._id:
                if "error" in msg:
                    raise RuntimeError(msg["error"])
                return msg.get("result", {})

    # --- Runtime-free helpers -------------------------------------
    def center_of(self, selector):
        root = self.send("DOM.getDocument", depth=-1)["root"]["nodeId"]
        node = self.send("DOM.querySelector",
                         nodeId=root, selector=selector)["nodeId"]
        if not node:
            return None
        self.send("DOM.scrollIntoViewIfNeeded", nodeId=node)
        quad = self.send("DOM.getBoxModel", nodeId=node)["model"]["content"]
        return ((quad[0] + quad[4]) / 2, (quad[1] + quad[5]) / 2)

    def click(self, selector):
        pos = self.center_of(selector)
        if not pos:
            raise LookupError(selector)
        x, y = pos
        self.send("Input.dispatchMouseEvent", type="mouseMoved", x=x, y=y)
        time.sleep(random.uniform(0.05, 0.18))
        self.send("Input.dispatchMouseEvent", type="mousePressed",
                  x=x, y=y, button="left", clickCount=1, buttons=1)
        time.sleep(random.uniform(0.04, 0.11))
        self.send("Input.dispatchMouseEvent", type="mouseReleased",
                  x=x, y=y, button="left", clickCount=1, buttons=0)

    def type_text(self, text):
        for ch in text:
            self.send("Input.dispatchKeyEvent", type="keyDown", text=ch)
            self.send("Input.dispatchKeyEvent", type="keyUp", text=ch)
            time.sleep(random.uniform(0.09, 0.22))

tab = open_tab_socket()
tab.send("DOM.enable")
tab.send("Page.enable")
tab.send("Page.navigate", url="https://example.com")
time.sleep(2.5)
tab.click("input[name=email]")
tab.type_text("you@example.com")

Note what's not here: no ChromeDriver, no --enable-automation, no Runtime.

Selenium antidetect: where it fits

You can keep Selenium if your test suite is already written in it. The change is the same: don't let Selenium launch the browser. Point it at a running profile with debuggerAddress:

from selenium import webdriver

opts = webdriver.ChromeOptions()
opts.add_experimental_option("debuggerAddress", "127.0.0.1:9222")
driver = webdriver.Chrome(options=opts)   # attaches, does not launch
driver.get("https://example.com")

Caveat: attached or not, Selenium's own element interaction goes through the W3C WebDriver layer, which is a separate, detectable surface — and driver.execute_script is Runtime.evaluate under a different name. Attaching removes the launch-time tells; it does not make WebDriver invisible. For sensitive flows, use Selenium for orchestration and drop to raw CDP Input for the clicks that matter.

The plain-HTTP alternative

Not everyone wants to hand-roll a CDP client, and plenty of automation lives in n8n, Make, Zapier, PowerShell, C#, or a bash script. A plain-HTTP automation layer exposes the same operations as ordinary REST calls against a running profile:

curl -X POST http://127.0.0.1:4480/api/profiles/PROFILE_ID/goto \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}'

curl -X POST http://127.0.0.1:4480/api/profiles/PROFILE_ID/click \
  -H "Content-Type: application/json" \
  -d '{"selector":"button[type=submit]"}'

Dual Login exposes exactly this alongside raw CDP: goto, click, type, screenshot, OCR-based clicks (click text that has no selector — canvas widgets, images, embedded apps), and network capture. Every endpoint runs the Runtime-free path described above, so a shell script gets the same trusted-event behaviour as a hand-written CDP client. It's also the practical option for driving profiles on a remote machine.

Realistic pacing

Once the technical tells are gone, behaviour becomes the discriminator. You don't need a physics engine — you need to stop being metronomic.

  1. Vary everything. Sample delays from a distribution, not a constant. Add occasional long pauses (a human reads, gets distracted, switches tabs).
  2. Move before you click. A few intermediate mouseMoved events along a slightly curved path cost nothing and remove a hard signal.
  3. Don't act at load. Real users need 1–3 seconds to orient after a page paints.
  4. Scroll like a reader. Multiple Input.dispatchMouseEvent with type: "mouseWheel" and varying deltaY, not one jump to the bottom.
  5. Rate-limit per profile, not globally. Twenty profiles doing five actions a minute each is normal. One profile doing a hundred is not.
  6. Keep sessions warm. Constant fresh-cookie logins look worse than a profile with history. Dual Login profiles keep sealed, isolated storage that survives restarts and moves between machines — see managing multiple accounts for the operational side.

And the part no code fixes: your exit IP. A perfect fingerprint behind a burnt datacenter subnet still gets challenged. Match proxy type to the platform's tolerance — the residential vs datacenter comparison covers the trade-offs. Per-profile proxies with auto-matched timezone, locale, and geolocation, plus native WebRTC masking so the real IP never leaks, are table stakes for this kind of work.

Pre-flight checklist

Before you scale a script from one profile to fifty:

  • Browser was launched normally, then attached to — never launched by the script.
  • navigator.webdriver is false in a real page, verified manually.
  • No Runtime.enable on any hot path (grep your code for evaluate, $eval, waitForFunction).
  • defaultViewport: null — no device-metrics override.
  • Clicks are coordinate-based Input events, not JS .click().
  • Every profile has its own proxy, and timezone/locale/geo match that exit IP.
  • Delays are randomised; no action fires within 500 ms of page load.
  • disconnect() on exit, not close().
  • Fingerprint verified on a checker page from inside the profile itself.

FAQ

Does connecting over CDP set navigator.webdriver to true?

No. navigator.webdriver reflects how the browser was launched — specifically the automation switches — not whether a debugger is currently attached. Attaching with puppeteer.connect() to a browser started without --enable-automation leaves it false. Launching with puppeteer.launch() sets it true by default.

Can a website detect that a debug port is open?

Not directly. Page JavaScript can't scan your local ports or read the DevTools HTTP endpoint (it's bound to localhost and rejects cross-origin requests without the right Host header). What a page can detect is behavioural evidence of an attached debugger — chiefly the Runtime serialisation side effect. Skip Runtime.enable and that channel closes.

Is Input.insertText detectable compared to real typing?

It's not "detectable" as a protocol call, but it's distinguishable by behaviour: it produces input events without keydown/keyup, and it delivers the whole string instantly. Any form with a keystroke listener sees an anomaly. Use dispatchKeyEvent with randomised gaps for credentials, search boxes, and short fields; save insertText for long-form content.

Should I use headless mode?

Prefer headful for anything sensitive. Modern headless is much closer to headful, but differences remain in GPU/ANGLE strings, font sets, and codec support — and headless usually runs on server hardware whose characteristics contradict the fingerprint you're claiming. If you need unattended operation, run headful inside a virtual display or keep windows off-screen rather than switching to headless.

Does this work with Playwright?

Yes, with the same principle: use chromium.connectOverCDP(endpoint) rather than chromium.launch(). Be aware that Playwright's high-level locator API leans on evaluated JavaScript in the page, so for the strictest work you'll still want to drop to a CDP session for input.

What if the element has no usable selector?

Use OCR. Screenshot the viewport, locate the text or control visually, and dispatch a trusted click at those coordinates. This is the reliable path for canvas-rendered UIs, embedded apps, image buttons, and anything inside a closed shadow root — and it's built into Dual Login's automation endpoints as ocr-click / ocr-read.

Final thoughts

Automating Chrome over CDP without being detected isn't about finding a clever patch. It's about not creating the signals in the first place: attach to a real, fingerprinted browser instead of launching an instrumented one, stay off Runtime, act through DOM + Input so your events are trusted, and pace yourself like a person. Do those four things and the remaining risk moves back where it belongs — proxy quality and account behaviour.

The one piece you can't build cheaply is the fingerprinted browser itself. Consistent canvas, WebGL, audio, fonts, screen, UA, timezone, languages, and geolocation — applied natively in the browser core so they hold up inside Web Workers and iframes — is engine work, not a userscript. That's the foundation everything above sits on. If you're weighing options, the comparison page and the alternatives roundup lay out what to look for, and pricing is straightforward.

Dual Login's free plan gives you 10 profiles with no credit card, full raw-CDP and HTTP automation included, so you can test every technique in this guide before paying anything. Create an account or download the desktop app for Windows, macOS, or Linux.

As always: automation is a tool for legitimate work — marketing operations, e-commerce, agency account management, QA, research, privacy. Following each platform's terms is your responsibility.

Run every account like a separate device

Dual Login gives each profile a real fingerprint, its own proxy and sealed storage — free plan, no card required.

More reading

Playbooks

Scaling Ad Accounts Without Bans: An Operator's Playbook

Scaling ad accounts is not a media buying problem. It is an operations problem. Most teams that lose accounts at scale do not lose them because their ads were bad or their offers were shady — they lose them because their ad account management was improvised: shared logins, mismatched payment methods, one buyer touching twelve accounts from one browser, and no plan for the day a restriction lands. The platforms' automated risk systems do not read your inten

Guides

Separate Browser Profiles for Each Ad Account: 2026 Guide

Separate Browser Profiles for Each Ad Account: 2026 Guide Ask any media buyer who has lost a Business Manager what killed it, and you'll rarely hear about a policy violation. You'll hear about linking. One account gets flagged for something minor — a rejected creative, a chargeback on a card, a login from a new country — and within 48 hours every other account that shared a browser, an IP, or a payment method with it goes down too. Not because those accou

Playbooks

How to Recover a Banned Facebook Ad Account (2026 Playbook)

How to Recover a Banned Facebook Ad Account (2026 Playbook) "Your ad account has been disabled." If you buy media for a living, that sentence lands somewhere between a parking ticket and a house fire, depending on how much of your revenue runs through that account. The good news, which nobody tells you while you're panicking: a meaningful share of Facebook ad account bans are automated false positives, and Meta reverses them when you appeal correctly. The