Cloudflare Turnstile Infinite Loop on Automated Chromium: Headless Bypass Flags

Root Cause Diagnosis

Turnstile re-issues the challenge when the browser’s signals fail its risk check, so the widget spins or reloads indefinitely instead of returning a token. The usual failing signals are navigator.webdriver === true, a HeadlessChrome user agent, a SwiftShader/software WebGL renderer, missing window.chrome and plugin objects, CDP side effects such as Runtime.enable, and a TLS/HTTP2 fingerprint that doesn’t match the claimed Chrome version. A second cause is state loss: an ephemeral profile, blocked third-party cookies, or blocked requests to challenges.cloudflare.com prevent the verification result from persisting, so the challenge restarts.

Quick Fix

Run full Chrome (not the headless shell) in new headless mode with the automation switches removed:

google-chrome --headless=new --disable-blink-features=AutomationControlled \
  --user-data-dir=/tmp/chrome-profile --lang=en-US https://example.com

If that still loops, run headed inside a virtual display:

xvfb-run -a --server-args="-screen 0 1920x1080x24" \
  google-chrome --disable-blink-features=AutomationControlled \
  --user-data-dir=/tmp/chrome-profile https://example.com

If the target is your own site, skip evasion entirely and use Cloudflare’s test keys (Step 1).

Step-by-Step Resolution

1. Own site: use Turnstile test keys or a WAF skip rule

Swap the production keys in your staging or CI environment:

PurposeSitekey
Always passes (visible)1x00000000000000000000AA
Always blocks (visible)2x00000000000000000000AB
Forces interactive challenge3x00000000000000000000FF
PurposeSecret key
Always passes1x0000000000000000000000000000000AA
Always fails2x0000000000000000000000000000000AA

Alternatively, add a rule at Cloudflare Dashboard → Security → WAF → Custom rules with action Skip for your CI egress IP or a secret header.

2. Use full Chrome in new headless mode, not the headless shell

Since Chrome 132, the legacy headless mode exists only in the separate chrome-headless-shell binary, which lacks many browser features and is easy to fingerprint.

See also  Playwright async wait_for_selector timing out on dynamic Shadow DOM elements

Playwright (Python):

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    ctx = p.chromium.launch_persistent_context(
        user_data_dir="/tmp/pw-profile",
        channel="chrome",              # real Chrome, not chromium-headless-shell
        headless=True,                 # new headless when channel="chrome"
        locale="en-US",
        timezone_id="America/New_York",
        viewport={"width": 1920, "height": 1080},
        args=["--disable-blink-features=AutomationControlled"],
        ignore_default_args=["--enable-automation"],
    )
    page = ctx.new_page()
    page.goto("https://example.com")

Puppeteer:

const puppeteer = require('puppeteer');

const browser = await puppeteer.launch({
  headless: true, // new headless in Puppeteer >= 22
  userDataDir: '/tmp/pptr-profile',
  ignoreDefaultArgs: ['--enable-automation'],
  args: [
    '--disable-blink-features=AutomationControlled',
    '--lang=en-US',
    '--window-size=1920,1080',
  ],
});

Install real Chrome if it is missing:

npx playwright install chrome

3. Keep the user agent and Client Hints consistent

The --user-agent flag changes only the UA string. It does not update sec-ch-ua, sec-ch-ua-platform or navigator.userAgentData, and Turnstile compares them. Prefer not overriding the UA at all. If you must, set the UA and its metadata together over CDP:

client = ctx.new_cdp_session(page)
client.send("Emulation.setUserAgentOverride", {
    "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
    "userAgentMetadata": {
        "brands": [{"brand": "Chromium", "version": "141"},
                   {"brand": "Google Chrome", "version": "141"}],
        "fullVersion": "141.0.0.0",
        "platform": "Linux",
        "platformVersion": "6.5.0",
        "architecture": "x86",
        "model": "",
        "mobile": False,
    },
})

Match the version numbers to the installed Chrome (google-chrome --version).

4. Persist the profile

Always use --user-data-dir (Puppeteer userDataDir, Playwright launch_persistent_context). A fresh incognito-style context on every run discards the clearance state Turnstile sets, which produces a repeat challenge.

5. Do not block challenge resources or third-party cookies

  • Remove any request interception that aborts script, iframe, font or image resources.
  • Allow https://challenges.cloudflare.com/* and its subresources.
  • Do not launch with --disable-features=ThirdPartyStoragePartitioning or a policy that blocks third-party cookies.
  • Keep JavaScript enabled and do not use --disable-web-security.

6. Align environment signals

export TZ=America/New_York
export LANG=en_US.UTF-8

The timezone and locale should agree with the egress IP’s geolocation. Install a normal font set so font enumeration isn’t empty:

sudo apt-get install -y fonts-liberation fonts-noto-color-emoji fonts-dejavu-core

7. Verify what the page sees

Open a diagnostics page in the same launch configuration and check these values:

console.log(navigator.webdriver, navigator.userAgent, navigator.languages,
            navigator.plugins.length, !!window.chrome);

Expected: webdriver is false or undefined, the UA has no HeadlessChrome, languages is non-empty, and window.chrome is truthy.

See also  Fixing Selenium target window already closed error during fast alert handling

8. Check the server-side verification path

A loop after the widget shows “Success” comes from your backend, not the browser. Confirm the siteverify call:

curl -s https://challenges.cloudflare.com/turnstile/v0/siteverify \
  -d "secret=$TURNSTILE_SECRET" -d "response=$TOKEN" -d "remoteip=$CLIENT_IP"

Inspect error-codes: invalid-input-secret means the secret doesn’t match the sitekey, timeout-or-duplicate means the token is older than 300 seconds or was already redeemed, and hostname mismatches mean the widget is embedded on an unregistered domain.

Why This Fails for Some Users

  • Datacenter or flagged IPs: No flag combination compensates for a poor IP reputation. Cloud ASNs, shared proxies and VPN exits are frequently challenged on every request.
  • Outdated Chrome: Old builds (for example, Debian or Alpine chromium packages several majors behind) fail version-plausibility checks. Use current stable Chrome.
  • Software rendering: Docker and VM hosts without a GPU expose a SwiftShader or llvmpipe WebGL renderer, which is a strong bot signal. Use GPU passthrough or a host with a real GPU where possible.
  • CDP-based detection: Puppeteer and Playwright both enable CDP domains (Runtime.enable) that page scripts can detect. Launch flags cannot remove this, and it is the most common reason a correctly flagged browser still loops.
  • TLS/HTTP2 fingerprint mismatch: Intercepting proxies (mitmproxy, corporate TLS inspection) and non-Chrome HTTP stacks produce a JA3/JA4 or HTTP/2 settings fingerprint that contradicts the Chrome UA.
  • Container limits: Small /dev/shm causes renderer crashes that look like a stuck widget. Add --disable-dev-shm-usage or mount a larger tmpfs (--shm-size=1g).
  • --no-sandbox in Docker: Required as root, but it changes some feature availability. Run as a non-root user with --cap-add=SYS_ADMIN or a seccomp profile so you can drop it.
  • Clock skew: A system clock off by more than a few minutes invalidates tokens and can break challenge timing. Sync with chronyc makestep.
  • Interactive challenges: Managed-mode widgets sometimes require a click inside a cross-origin iframe with a closed shadow root. Scripted selectors cannot reach it, so use Playwright’s frame locators, or set the widget appearance to interaction-only or execute on sites you control.
  • Extensions and flags that change fingerprints: --disable-gpu, --disable-extensions, --mute-audio and custom --window-size values that don’t match the screen size can each lower the trust score. Add flags only when you have a specific reason.
See also  Python script crashing with Segmentation Fault (core dumped) inside Docker Alpine

Leave a Comment