Troubleshooting
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.
By ZeroCaptcha Engineering7 min readPublished Updated
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. For your own site’s tests you need no solver at all: see 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*and600*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.
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 and Selenium, undetected-chromedriver and Cloudflare Turnstile.
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:
- Load the page and read the widget’s
data-sitekey, and itsdata-actionanddata-cdatawhen it sets them. - Send them with the page URL to a solving API and wait for the token.
- Write the token into the form’s
cf-turnstile-responseinput and submit.
Install Playwright (npm install playwright, then npx playwright install chromium), set
ZEROCAPTCHA_API and ZEROCAPTCHA_KEY, and save this as login.mjs:
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. - 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 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.
- 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.
More on this flow is on the Cloudflare Turnstile solver for Playwright page, and other languages are on the 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
and Playwright and the 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.
Sources
- Cloudflare Turnstile: testing (checked 1 October 2026).
- Cloudflare challenges: supported browsers, overview and troubleshooting (checked 1 October 2026).
- Cloudflare bots: bot detection engines, Bot Fight Mode plan and Super Bot Fight Mode plan for Pro (checked 1 October 2026).
- Cloudflare Turnstile: client-side error codes (checked 1 October 2026).
- Cloudflare Turnstile overview and privacy addendum (checked 1 October 2026).
- Chrome: headless mode (checked 1 October 2026).
- Playwright: browsers (checked 1 October 2026).
- puppeteer-extra-plugin-stealth README (checked 1 October 2026).
The team that builds and runs the ZeroCaptcha API. Articles are drafted with AI tools, then checked against the API's code and the primary sources each one cites.