DEV Community

Sho Naka
Sho Naka

Posted 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: if a bot check fails only under automation, stop hardening the launch flags. Start the browser yourself with a remote debugging port, attach to it with connect_over_cdp, and open one page target before you connect.

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 no matter which flags or profile that browser carried.
  • The answer: the difference the check reacted to was not which binary was running but who started it. A Chrome I launched by hand, attached to afterwards over CDP, passed on the first try.
  • Do this next: if you are on the flag-tweaking treadmill, jump to the attach path below. The three errors after it are the ones you will hit in the next ten minutes.

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 real Chrome install rather than the bundled Chromium.
  • --disable-blink-features=AutomationControlled, the flag most commonly cited for this class of problem.
  • A freshly created browser profile, on the theory that something in the old one was poisoned.
  • Clicking the alternative sign-in button, which only moved the failure to a different provider's page.

The pattern in that list is worth naming. Each attempt changes something about the browser. None of them changes the thing that is actually different between the run that fails and the run that works.

The discriminator is the launcher, not the binary

Playwright attaches a set of automation switches when it starts a browser process. Selecting channel="chrome" changes which executable those switches are applied to; it does not remove them. So "use the real Chrome" and "hide the automation flag" are both edits inside a space where the answer is not.

Attaching over CDP sits outside that space. The browser is already running. Nobody added a switch on its command line for automation, because the command line was written before any automation existed. The debugger connects afterwards.

flowchart TD
    A[Need to drive a logged-in browser] --> B{Who starts the process?}
    B -->|Playwright launches it| C[Automation switches on the command line]
    C --> D[Bot check refuses]
    B -->|You start it by hand| E[Ordinary Chrome command line]
    E --> F[Attach afterwards over CDP]
    F --> G[Bot check sees an ordinary browser]

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 borrow it.

Three errors you will hit right after switching

1. Zero page targets breaks the connection itself

This one cost me the most time, twice, because the error text points at downloads and the cause is tabs.

BrowserType.connect_over_cdp: Protocol error (Browser.setDownloadBehavior):
Browser context management is not supported.
Enter fullscreen mode Exit fullscreen mode

If the running Chrome has no page target open, connect_over_cdp throws while connecting. You cannot inspect browser.contexts afterwards and recover, because there is no browser yet. Open a tab first:

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 136 and later will not expose a debugging port on the standard user profile. This is a deliberate protection, not a bug to route around: your everyday browsing session should not be drivable by anything that can open a socket on localhost. Use a separate --user-data-dir and treat it as a purpose-built automation profile.

3. Closing the context logs you out of your own tooling

When you launch a browser, closing it at the end is hygiene. When you attach to one, ctx.close() closes a window a human logged into, and the next run starts at the login page again. Track which mode you are in and skip the teardown when attached:

def close_context(ctx, attached: bool) -> None:
    if attached:
        return
    ctx.close()
Enter fullscreen mode Exit fullscreen mode

The profile copy that cannot work

Somewhere around the second hour, moving an already-authenticated profile directory into the automation profile starts to look like a shortcut. On macOS it is not one.

Chrome encrypts cookies with a Safe Storage key held in the system Keychain, and that key is bound to the profile it was created for. Copy the directory somewhere else and the cookie file travels intact and undecryptable. The visible result is confusing rather than obviously broken: remembered account hints survive, so the login page greets you by name and then asks for the password anyway.

Keep the authenticated session where it already lives and attach to it. Do not move it.

Whether it was worth it

The dashboard opened. The deploy log carried one line the pipeline had never surfaced: the newest article had not been deployed because the account had reached a posting-rate limit, with a link to the FAQ describing it. Forty minutes of comparing article metadata and branches had been spent on a question the platform had already answered in a sentence.

That is the second lesson, and possibly the more expensive one. When a service reports success and the public result disagrees, read the service's own log before you start diffing your side. The reason automation was worth fixing here is not that clicking through the dashboard is slow. It is that a log you cannot read from a script is a log you will not read at three in the morning.

Checklist

  1. Confirm the failure is launch-related: open the same URL by hand on the same machine.
  2. Start Chrome yourself with --remote-debugging-port and a dedicated --user-data-dir.
  3. Log in once in that window.
  4. Check /json/list for a page target and PUT /json/new?about:blank if there is none.
  5. connect_over_cdp, then take browser.contexts[0] rather than creating a context.
  6. Skip close() on attached contexts.
  7. Keep a fallback to the normal launch path so the script still runs where no debug port is listening.

FAQ

Does this defeat bot detection? No, and it is not intended to. It reads a dashboard I own with a session a human established by logging in. Nothing here forges a credential or bypasses a check that was protecting someone else's resource.

Will attaching always pass? Unknown. It passed in this environment on the runs described. Detection services change, and a service that also fingerprints the debugging protocol would see straight through this.

Does it work with Puppeteer or Selenium? The launch-versus-attach distinction is not Playwright-specific; both have connect-to-running-browser modes. The exact error strings above are Playwright's.

Why not headless? The point is a session a human logged into. Attaching to a headed browser that a person authenticated is the mechanism, not an inconvenience.

What I did not verify

The internal logic of the bot-detection service is not public, and I make no claim about what it inspects. What is measured here is narrow: two launch paths, one environment, one site, the runs on 2026-09-06 and 2026-09-09. Whether the same contrast holds on other sites, other detection vendors, or other platforms is untested. If you reproduce it somewhere else, that is a data point I would like to see.

Top comments (0)