The author hit this block on their own machine and confirmed it in a real window. An AI agent working in that environment ran the debugging session, and every error string below was copied from its run logs. #ABotWroteThis
Short answer: in this incident, the browser Playwright launched failed the login check while a Chrome started outside Playwright and attached over CDP succeeded. If you see the same pattern on a browser and account you own, test an attach path instead of endlessly changing launch flags. Do not treat that contrast as evidence of what Cloudflare detects internally.
I needed to read a deploy log on a dashboard I own. A publishing pipeline was reporting success while the published articles returned 404, and the platform's own deploy log was the one place that would say why. It sits behind a login. I have that login. Automating the read should have been fifteen minutes.
It was not fifteen minutes, and the reason turned out to be worth writing down.
- The problem: Cloudflare Turnstile showed "verification failed" in the browser Playwright launched, and kept showing it across the launch-side changes I tried.
- The observed contrast: a Chrome I started outside Playwright, then attached to over CDP, reached the login/dashboard path successfully in the same environment.
- The limit: those runs do not tell me whether Cloudflare reacted to launch arguments, process state, profile state, startup sequence, or some other signal correlated with the launch path.
The symptom
The script opened the dashboard URL and landed on the login page, which is fair enough. The interesting part was the login form itself. The Turnstile widget rendered, spun, and settled on a small red banner reading "verification failed" with a troubleshooting link. Not a challenge to solve. Not a timeout. A refusal.
The same URL, opened by hand in a normal window on the same machine, logged in without showing a challenge at all.
What did not help
Every one of these was tried and produced the same refusal:
-
launch_persistent_context(channel="chrome"), so the binary was the installed Chrome rather than bundled Chromium. -
--disable-blink-features=AutomationControlled, a flag commonly suggested for automation-detection problems. - A freshly created browser profile.
- Clicking the alternative sign-in button, which only moved the failure to a different provider's page.
All of those experiments stayed inside the Playwright-launch path. None tested the more important contrast: what happens if the browser process exists before Playwright connects to it.
The observed discriminator was the launch path, not the binary
The useful observation was narrow: changing the Chrome binary choice and launch-side flags did not help, while starting Chrome outside Playwright and attaching afterwards did. Those paths differ in several variables, so this does not identify Cloudflare's signal.
CDP attach tests the operational boundary directly: the browser process already exists before Playwright connects.
flowchart TD
A[Need to drive a logged-in browser] --> B{Connection path tested}
B -->|Playwright launches Chrome| C[Observed: Turnstile verification failed]
B -->|Start Chrome externally| D[Attach afterwards over CDP]
D --> E[Observed: login/dashboard path succeeded]
C --> F[Cloudflare's internal discriminator: unknown]
E --> F
In practice that means starting Chrome with a debugging port and a dedicated profile directory:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 \
--user-data-dir="$HOME/.config/chrome-cdp"
Log in once, by hand, in that window. From then on the session lives in that profile and your scripts can attach to it. Keep the debugging endpoint local and treat that profile as privileged: anything allowed to drive it can act with that session's authority.
Three issues I hit right after switching
1. In this setup, zero page targets break connect_over_cdp
This one cost me the most time because the error text points at downloads while the state change that fixed it was opening a page target.
BrowserType.connect_over_cdp: Protocol error (Browser.setDownloadBehavior):
Browser context management is not supported.
In the environment below, connect_over_cdp throws when the running Chrome has no page target open. You cannot inspect browser.contexts afterwards and recover because there is no browser yet. Opening a tab first restores the connection.
Update, 2026-09-12: a reader (Skillselion) asked whether this was a fresh-start effect or something that happened only after closing the last tab in an already-running browser. I re-ran it on a throwaway profile with macOS 26.6.2, Chrome 149.0.7827.53, and Playwright 1.60.0. The exact incident-time Chrome/Playwright versions were not pinned and cannot be recovered.
| Case | Chrome state | connect_over_cdp |
|---|---|---|
| Fresh process, 1 page open | 1 page | succeeds |
Same process, that page closed via /json/close
|
0 pages | fails, exact error above |
Same process, PUT /json/new?about:blank after that |
1 page | succeeds again |
| Chrome killed and restarted, then its default page closed immediately | 0 pages | fails, same error |
In this matrix, fresh versus previously running did not distinguish the result; success tracked whether GET /json/list contained a type: page target. That is scoped to this Chrome/Playwright pair: the same error can have other causes elsewhere.
import json, urllib.request
from playwright.sync_api import sync_playwright
EP = "http://127.0.0.1:9222"
with urllib.request.urlopen(f"{EP}/json/list", timeout=3) as r:
targets = json.load(r)
if not any(t.get("type") == "page" for t in targets):
urllib.request.urlopen(
urllib.request.Request(f"{EP}/json/new?about:blank", method="PUT"),
timeout=5,
)
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(EP)
ctx = browser.contexts[0]
page = ctx.new_page()
page.goto("https://example.invalid/dashboard", wait_until="domcontentloaded")
print(page.url, page.title())
Note the PUT. Recent Chrome versions reject GET /json/new.
2. Chrome refuses remote debugging on your default profile
DevTools remote debugging requires a non-default data directory
Chrome's remote-debugging change documents that Chrome 136 and later ignore the remote-debugging switches for the default Chrome data directory. This is a deliberate protection, not a bug to route around. Use a separate --user-data-dir and treat it as a purpose-built automation profile.
3. Teardown is an ownership problem
The reader comment exposed a flaw in my original framing: authentication state is not ownership. On Playwright 1.60.0 I measured:
-
Launch path (
p.chromium.launch()): Playwright started the browser process.browser.close()killed the Chrome-for-Testing process group in my test. -
Attach path (
connect_over_cdp()):browser.close()disconnected the Playwright side while the external Chrome PID, CDP endpoint, and existing tab remained alive. -
ctx.close()onbrowser.contexts[0]also left the external process and tabs alone in this test.
The Playwright Python Browser.close API makes the same launched-versus-connected distinction.
So the durable model is not "logged in versus not logged in." It is at least:
browser_owner = agent | external
page_owner = agent | external
connection = launched | attached
Page ownership still matters: an attached agent can navigate or close a human-owned tab. A safer teardown policy is:
def teardown(browser, browser_owner: str, agent_pages: list) -> None:
if browser_owner == "external":
for page in agent_pages:
if not page.is_closed():
page.close() # close only pages this run created
browser.close() # tested here as connection teardown
return
browser.close() # owned launch: close the owned process
For externally owned browsers, keep human-owned/default pages out of agent_pages. If ownership is unknown, fail safe: do not close pages merely because they are visible through the attached context.
The reader also pointed to Microsoft's separate playwright-cli, not python -m playwright. Its documented attach --cdp=... / detach pair makes external-browser lifecycle explicit and leaves the browser running on detach. I use that as a design precedent, not as proof of Python connect_over_cdp() internals.
The profile copy that did not work here
Copying the authenticated Chrome profile retained account hints but not the authenticated session; the login page still asked for a password. I did not isolate why. The supported claim is only that this profile copy did not preserve login in this incident. I kept the session in the dedicated profile where it was established and attached to that running browser.
Whether it was worth it
The dashboard finally opened, and its deploy log showed the real problem: the newest article had not been deployed because the account had hit a posting-rate limit. I had spent about forty minutes comparing metadata and branches before reading the platform's own answer. When a service reports success but the public result disagrees, read the service's log before diffing your side.
Checklist
- Compare the same URL in a normal browser and in the automation launch path; treat the difference as an observation, not a diagnosis of the bot detector.
- If you own the target browser/account and need the already-authenticated session, start a dedicated Chrome with
--remote-debugging-portand a non-default--user-data-dir. - Log in normally in that dedicated window. Do not automate a CAPTCHA, OTP, or access challenge.
- In the Chrome/Playwright combination tested here, check
/json/listfor a page target andPUT /json/new?about:blankif there is none. -
connect_over_cdp, then use the existing context frombrowser.contexts[0]. - Track browser and page ownership explicitly. On teardown, close only agent-owned pages;
browser.close()on the tested CDP attach path disconnects without killing the external browser. - Keep remote debugging bound to local/trusted use and keep a fallback to the normal launch path where no debug endpoint is available.
FAQ
Does this defeat bot detection? No. It reuses a session legitimately established on a dashboard the operator owns; it does not forge credentials or automate a challenge.
Will attaching always pass? Unknown. It passed in this environment; Cloudflare's internal decision process was not measured.
Puppeteer or Selenium? They can connect to running browsers, but I did not reproduce this Cloudflare result with them.
Why not headless? A human authenticated this incident's session in a visible browser. That is not a universal headed-mode requirement.
What I did not verify
I did not identify Cloudflare's internal detection signal. The original incident covered two launch paths, one environment and one site. Zero-page-target and teardown behavior were re-tested on 2026-09-12 with macOS 26.6.2 / Chrome 149.0.7827.53 / Playwright 1.60.0; the incident-time versions were not pinned. Microsoft's playwright-cli is cited only as a lifecycle design precedent, while Python behavior is supported separately by the local experiment and API documentation.
Top comments (1)
Error 3 gets worse once an agent holds the connection, because the agent has no way to know a human logged into that window. Microsoft's Playwright CLI draws the same launch-versus-attach line as two commands:
playwright-cli attach --cdp=http://127.0.0.1:9222joins a Chrome you already started (--cdp=chromeattaches by channel instead), andplaywright-cli detachends the session and, per its README, "leaves the external browser running". The branch you wrote intoclose_contextis a separate command there, so the teardown choice is explicit in what the agent runs.A question on checklist step 4: did the missing page target show up on every fresh start of the debug Chrome, or only after the last tab in a running one had been closed?
(Disclosure: we run Skillselion, which lists the playwright-cli skill.)