# Cloudflare Turnstile in Headless Browsers: Why It Fails and Fixes

> Why Cloudflare Turnstile fails in headless Chrome, Playwright and Puppeteer, what Cloudflare says about automated browsers, and a fix that uses an API token.

- Source: https://zerocaptcha.io/blog/cloudflare-turnstile-headless-browser
- Published: 2026-09-30
- Updated: 2026-10-01
- Author: ZeroCaptcha Engineering

Cloudflare Turnstile fails in headless browsers because Cloudflare looks for automation and says
so: its testing docs state that "Automated testing suites (like Selenium, Cypress, or Playwright)
are detected as bots by Turnstile", and its challenge docs that "Automated browsers are not
supported for solving production challenges." Headless mode adds signals on top: Cloudflare's
JavaScript detections engine "identifies headless browsers", and Bot Fight Mode names "headless
browsers" among the bots it detects. The dependable fix is to split the work: the browser loads
and submits the page, and a solving API provides the token.

Automate only sites you are allowed to: your own, a client's, or one whose terms permit it. See
[responsible captcha automation](https://zerocaptcha.io/guides/responsible-captcha-automation). For your own site's
tests you need no solver at all: see
[test Cloudflare Turnstile in CI](https://zerocaptcha.io/guides/test-cloudflare-turnstile-in-ci).

## What Cloudflare says about automated browsers

Everything below is Cloudflare's own wording (checked 1 October 2026):

- **Turnstile testing page:** "Automated testing suites (like Selenium, Cypress, or Playwright)
  are detected as bots by Turnstile".
- **Supported browsers for challenges:** "Automated browsers are not supported for solving
  production challenges." and "Browser automation frameworks, such as Selenium, Puppeteer,
  Playwright, and Cypress, are not supported for solving production challenges." Turnstile runs
  on the same Challenge Platform: it "is the same underlying technology powering Turnstile".
- **Bot detection engines:** JavaScript detections "identifies headless browsers and other
  malicious fingerprints".
- **Bot Fight Mode (Free plan):** detects "Simple bots from cloud hosting providers and headless
  browsers". Super Bot Fight Mode on Pro detects "Simple bots and headless browsers".
- **Turnstile's error table:** the `300*` and `600*` families are "Generic challenge failure",
  with the note "Bot behavior detected." These are the codes an automated browser usually sees;
  see [Cloudflare Turnstile error codes](https://zerocaptcha.io/blog/cloudflare-turnstile-error-codes).

Two points follow. Detection is aimed at the automation framework, not only at headless mode, so
a headed browser under Playwright or Selenium is still covered by these statements. And some
signals come from outside the browser: Turnstile's privacy addendum lists the signals it collects
as "client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin", and
Bot Fight Mode singles out traffic "from cloud hosting providers". A browser running in a data
center carries that address whatever the browser does.

## Headless Chrome has two modes

Chrome changed its headless mode, and automation tools followed:

- **New headless.** Chrome's docs say headless mode now shares its code with regular Chrome; you
  start it with `--headless`.
- **The old headless, now a separate binary.** "Since Chrome 132.0.6793.0 the old Headless mode
  is only available as a standalone binary named `chrome-headless-shell`."
- **Playwright's default.** "Playwright ships a regular Chromium build for headed operations and a
  separate chromium headless shell for headless mode." Launching with `channel: 'chromium'` opts
  in to new headless, which Playwright describes as "the real Chrome browser, and is thus more
  authentic, reliable, and offers more features."

Switching to new headless removes the differences between the shell and real Chrome. It does not
remove the automation framework, which is what Cloudflare's statements are about. This article
makes no claim that either mode passes more often.

## What stealth tools change, and why it is a moving target

Stealth plugins and patched drivers, such as `puppeteer-extra-plugin-stealth` and
undetected-chromedriver, patch the values that give an automated browser away. The stealth
plugin's README names evasions such as `navigator.webdriver` and `user-agent-override`, and notes
that "The addition of `HeadlessChrome` to the user-agent" is only the most obvious giveaway. Its
author is candid about the limits, calling the work "a rather interesting cat and mouse game" and
adding: "It's probably impossible to prevent all ways to detect headless chromium".

The target moves on both sides. Chrome changed its headless mode in version 132, and Cloudflare
describes its checks only in general terms, as proof-of-work, proof-of-space, probing for web APIs
"and various other challenges for detecting browser-quirks and human behavior". A tool that
patches today's known signals cannot know which ones will be checked next, so a setup that passes
this month can stop passing without any change in your code. The details for each tool are in
[Puppeteer stealth and Cloudflare Turnstile](https://zerocaptcha.io/blog/puppeteer-stealth-cloudflare-turnstile) and
[Selenium, undetected-chromedriver and Cloudflare Turnstile](https://zerocaptcha.io/blog/selenium-undetected-chromedriver-cloudflare).

## The reliable split: browser for the page, API for the token

The browser is good at what the site needs from a browser: loading the page, keeping cookies,
filling fields and submitting. The token can come from elsewhere. The site's server checks the
token it receives in the form with Cloudflare's siteverify API, whose documented reply names no
browser or device apart from an Enterprise-only ephemeral ID. So your code can:

1. Load the page and read the widget's `data-sitekey`, and its `data-action` and `data-cdata`
   when it sets them.
2. Send them with the page URL to a solving API and wait for the token.
3. Write the token into the form's `cf-turnstile-response` input and submit.

Install Playwright (`npm install playwright`, then `npx playwright install chromium`), set
`ZEROCAPTCHA_API` and `ZEROCAPTCHA_KEY`, and save this as `login.mjs`:

```js
import { chromium } from "playwright";

const API = process.env.ZEROCAPTCHA_API;
const KEY = process.env.ZEROCAPTCHA_KEY;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function call(method, body, headers = {}) {
  const response = await fetch(`${API}/${method}`, {
    method: "POST",
    headers: { "Content-Type": "application/json", ...headers },
    body: JSON.stringify({ clientKey: KEY, ...body }),
    signal: AbortSignal.timeout(15_000),
  });
  const reply = await response.json();
  if (reply.errorId) throw new Error(`${method}: ${reply.errorCode}`);
  return reply;
}

async function solveTurnstile(websiteURL, websiteKey, action, cdata) {
  // The widget's data-action and data-cdata go in metadata, only when it sets them: many sites
  // check both when they verify the token.
  const task = { type: "TurnstileTaskProxyless", websiteURL, websiteKey, metadata: {} };
  if (action) task.metadata.action = action;
  if (cdata) task.metadata.cdata = cdata;
  // One Idempotency-Key per task: a retried create with it returns the same task.
  const { taskId } = await call("createTask", { task }, { "Idempotency-Key": crypto.randomUUID() });
  const deadline = Date.now() + 180_000;
  while (Date.now() < deadline) {
    await sleep(2_000);
    const result = await call("getTaskResult", { taskId });
    if (result.status === "ready") return result.solution.token;
  }
  throw new Error(`task ${taskId}: no token within 180 seconds`);
}

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://shop.example.com/login");

const widget = page.locator("form [data-sitekey]").first();
const websiteKey = await widget.getAttribute("data-sitekey");
const action = (await widget.getAttribute("data-action")) ?? undefined;
const cdata = (await widget.getAttribute("data-cdata")) ?? undefined;
const token = await solveTurnstile(page.url(), websiteKey, action, cdata);

// Put the token where the widget would have, creating the input if the widget never did.
await widget.evaluate((element, value) => {
  const form = element.closest("form");
  let input = form.querySelector('[name="cf-turnstile-response"]');
  if (!input) {
    input = Object.assign(document.createElement("input"), { type: "hidden", name: "cf-turnstile-response" });
    form.append(input);
  }
  input.value = value;
}, token);

await page.fill("#email", "me@example.com");
await page.fill("#password", process.env.SHOP_PASSWORD ?? "");
await Promise.all([
  page.waitForURL((url) => !url.pathname.startsWith("/login")),
  page.click('button[type="submit"]'),
]);
console.log("Logged in:", page.url());
await browser.close();
```

Run it with `node login.mjs`. The selectors (`#email`, `#password`) and the URL are placeholders
for your page.

## Details that matter

- **Submit at once.** The token works once and for 300 seconds; the API's ready reply includes
  `expiresAt`. If the site rejects the attempt for another reason, get a new token before
  retrying. See [Cloudflare Turnstile token expiry](https://zerocaptcha.io/guides/cloudflare-turnstile-token-expiry).
- **Callbacks.** Some pages read the token from the widget's JavaScript callback, not from the
  input, and ignore a value you write into it.
  [Submit a Cloudflare Turnstile token](https://zerocaptcha.io/guides/submit-cloudflare-turnstile-token) shows how to
  hand the token to the page in that case.
- **Let the widget try first, if you like.** Waiting a few seconds for the widget's own token and
  calling the API only when none appears saves solves when the widget passes. The Puppeteer and
  Selenium articles above build that fallback.
- **Cost.** A task is charged only when its token is ready; one that fails or times out costs
  nothing. See [pricing](https://zerocaptcha.io/pricing).
- **Proxies.** If a site rejects tokens that are fresh and correct, run the task through your
  own proxy; see [solve Cloudflare Turnstile with a proxy](https://zerocaptcha.io/guides/solve-cloudflare-turnstile-with-a-proxy).

More on this flow is on the [Cloudflare Turnstile solver for Playwright](https://zerocaptcha.io/cloudflare-turnstile-solver/playwright)
page, and other languages are on the [Cloudflare Turnstile solver](https://zerocaptcha.io/cloudflare-turnstile-solver)
page.

## When the browser never reaches the form

If every page answers "Just a moment…", the browser is facing a Cloudflare challenge page, not a
Turnstile widget in a form, and a token does not help. That page is passed with a `cf_clearance`
cookie. See [Cloudflare challenge page vs Cloudflare Turnstile](https://zerocaptcha.io/guides/cloudflare-challenge-vs-turnstile)
and [Playwright and the Cloudflare challenge](https://zerocaptcha.io/blog/playwright-cloudflare-challenge).
ZeroCaptcha's challenge task passes the page through your proxy and returns the cf_clearance cookie with the user agent it is bound to.
See the [Cloudflare WAF and 5-second challenge solver](https://zerocaptcha.io/cloudflare-challenge-solver).

## Sources

- [Cloudflare Turnstile: testing](https://developers.cloudflare.com/turnstile/troubleshooting/testing/)
  (checked 1 October 2026).
- [Cloudflare challenges: supported browsers](https://developers.cloudflare.com/cloudflare-challenges/reference/supported-browsers/),
  [overview](https://developers.cloudflare.com/cloudflare-challenges/) and
  [troubleshooting](https://developers.cloudflare.com/cloudflare-challenges/troubleshooting/)
  (checked 1 October 2026).
- [Cloudflare bots: bot detection engines](https://developers.cloudflare.com/bots/concepts/bot-detection-engines/),
  [Bot Fight Mode plan](https://developers.cloudflare.com/bots/plans/free/) and
  [Super Bot Fight Mode plan for Pro](https://developers.cloudflare.com/bots/plans/pro/) (checked 1
  October 2026).
- [Cloudflare Turnstile: client-side error codes](https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/)
  (checked 1 October 2026).
- [Cloudflare Turnstile overview](https://developers.cloudflare.com/turnstile/) and
  [privacy addendum](https://www.cloudflare.com/turnstile-privacy-policy/) (checked 1 October
  2026).
- [Chrome: headless mode](https://developer.chrome.com/docs/chromium/headless) (checked 1
  October 2026).
- [Playwright: browsers](https://playwright.dev/docs/browsers) (checked 1 October 2026).
- [puppeteer-extra-plugin-stealth README](https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth)
  (checked 1 October 2026).

## Questions

### Why does Cloudflare Turnstile fail in a headless browser?

Cloudflare detects automation. Its testing docs say automated testing suites such as Selenium, Cypress and Playwright are detected as bots by Turnstile, and its challenge docs say automated browsers are not supported for solving production challenges.

### Does running Chrome with a window instead of headless fix Cloudflare Turnstile?

Not reliably. Cloudflare's statements are about automation frameworks, not only about headless mode, so a headed browser driven by Playwright or Selenium is still an automated browser.

### How do I handle Cloudflare Turnstile in Playwright?

Split the work: Playwright loads the page and submits the form, and a solving API provides the token. Read the sitekey, create a task, write the token into the cf-turnstile-response input, then submit.
