DEV Community

Sho Naka
Sho Naka

Posted on Edited on AI-assisted

Cloudflare Blocked My Playwright Login. Attaching to a Chrome I Started Myself Walked Straight Through.

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"
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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() on browser.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. If you own the target browser/account and need the already-authenticated session, start a dedicated Chrome with --remote-debugging-port and a non-default --user-data-dir.
  3. Log in normally in that dedicated window. Do not automate a CAPTCHA, OTP, or access challenge.
  4. In the Chrome/Playwright combination tested here, check /json/list for a page target and PUT /json/new?about:blank if there is none.
  5. connect_over_cdp, then use the existing context from browser.contexts[0].
  6. 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.
  7. 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)

Collapse
 
skillselion profile image
Skillselion •

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:9222 joins a Chrome you already started (--cdp=chrome attaches by channel instead), and playwright-cli detach ends the session and, per its README, "leaves the external browser running". The branch you wrote into close_context is 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.)