Tutorial
Puppeteer Stealth and Cloudflare Turnstile: What It Can't Fix
What puppeteer-extra-plugin-stealth changes, why Cloudflare Turnstile can still refuse a stealthy browser, and a Puppeteer fallback that gets the token.
By ZeroCaptcha Engineering5 min readPublished Updated
puppeteer-extra-plugin-stealth hides the most common signs that Chrome is being driven by
Puppeteer, such as navigator.webdriver and the headless user agent, and that can let a
Cloudflare Turnstile widget pass on its own. It cannot guarantee it: Turnstile runs its own
checks in the browser and also considers things no plugin changes, such as your IP address’s
reputation. The robust pattern is to give the widget a few seconds and, if no token appears, get
one from a solving API. This tutorial explains what the plugin does and builds that fallback.
Automate only sites you are allowed to. See responsible captcha automation.
What the stealth plugin changes
The plugin is a set of evasions, each patching one thing a detection script might check. Its README lists, among others:
- the
navigator.webdriverflag, which istruein an automated browser; - the user agent, which says
HeadlessChromein headless mode; window.chromeandchrome.runtime, which a headless browser lacks;- plugins, MIME types, WebGL vendor strings and media codecs, which differ in headless builds;
- details of iframes that scripts use to tell automation apart.
Its author calls this a “cat and mouse game”: the evasions pass public detection tests, but complete evasion of every check may be impossible. And the package’s latest release, 2.11.2, is from March 2023 (npm, checked 1 October 2026), while Puppeteer has reached version 25 and Chrome has changed its headless mode since.
Why Cloudflare Turnstile can still say no
Cloudflare Turnstile does not only look for automation flags. It runs “a series of small non-interactive JavaScript challenges” (proof-of-work, proof-of-space, probing for web APIs, and checks for browser quirks and human behavior) and decides on Cloudflare’s side. Its privacy addendum lists the signals it collects: the client IP address, the TLS fingerprint, the User-Agent header and the sitekey. Cloudflare’s testing docs are blunt: “Automated testing suites (like Selenium, Cypress, or Playwright) are detected as bots by Turnstile”. Several of the inputs are out of any plugin’s reach:
- The network. A browser running in a data center, or behind an address with a poor history, is judged on that address.
- Consistency. A patched value that contradicts another signal, such as a user agent that claims one Chrome version while the browser behaves like another, is itself a signal.
- Interaction. In managed mode, the widget may ask for a click when it is unsure, and a scripted click is not the same as a person’s.
So the plugin raises the chance that the widget passes; it does not make it a certainty. Code that assumes it will pass eventually hangs on a widget that never produces a token.
The fallback pattern
Wait a few seconds for the widget’s own token. If none appears, ask the API for one and put it
where the widget would have. Puppeteer 25 needs Node.js 22.12 or later. Install it
(npm install puppeteer), set ZEROCAPTCHA_API and ZEROCAPTCHA_KEY, and save this as
login.mjs:
import puppeteer from "puppeteer";
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 puppeteer.launch();const page = await browser.newPage();await page.goto("https://shop.example.com/login", { waitUntil: "domcontentloaded" });
const widget = await page.waitForSelector("[data-sitekey]");const websiteKey = await widget.evaluate((el) => el.getAttribute("data-sitekey"));const action = (await widget.evaluate((el) => el.getAttribute("data-action"))) ?? undefined;const cdata = (await widget.evaluate((el) => el.getAttribute("data-cdata"))) ?? undefined;
// 1. Give the widget ten seconds to pass on its own.const passed = await page .waitForFunction(() => document.querySelector('[name="cf-turnstile-response"]')?.value, { timeout: 10_000, }) .then(() => true) .catch(() => false);
// 2. Otherwise, get a token from the API and put it where the widget would have.if (!passed) { const token = await solveTurnstile(page.url(), websiteKey, action, cdata); await page.$$eval( '[name="cf-turnstile-response"]', (inputs, value) => { for (const input of inputs) input.value = value; }, token, );}
await page.type("#email", "me@example.com");await page.type("#password", process.env.SHOP_PASSWORD ?? "");await Promise.all([page.waitForNavigation(), page.click('button[type="submit"]')]);console.log("Logged in:", page.url());await browser.close();Run it with node login.mjs. The same fallback works with puppeteer-extra and the stealth
plugin in place of plain Puppeteer: only the import and launch lines change.
Details that matter
- Ten seconds is a starting point. A widget that passes usually does so within a few seconds. Waiting longer only delays the fallback.
- The token is paid for only on the fallback path. When the widget passes on its own, no task is created. A task that fails costs nothing either.
- Callbacks. Some pages read the token from the widget’s JavaScript callback instead of the hidden input, and never see a value you write into the input. Submit a Cloudflare Turnstile token shows how to hand the token to the page in that case.
- Freshness. A token is valid for 300 seconds and works once, so submit straight after the fallback returns. See Cloudflare Turnstile token expiry.
When the whole page is a challenge
If the browser never reaches the form because every page shows “Just a moment…”, Puppeteer is facing a Cloudflare challenge page, not a Turnstile widget in a form. The stealth plugin faces the same limits there. See Cloudflare challenge page vs Cloudflare Turnstile and Cloudflare Turnstile in headless browsers.
Sources
- puppeteer-extra-plugin-stealth README, for its evasions and the “cat and mouse” caveat (checked 1 October 2026).
- npm registry:
puppeteer-extra-plugin-stealth2.11.2, published 1 March 2023, andpuppeteer25.12.0, which needs Node.js 22.12.0 or later (checked 1 October 2026). - Cloudflare Turnstile overview, testing and privacy addendum (checked 1 October 2026).
- Chrome headless mode (checked 1 October 2026).
- Cloudflare Turnstile: client-side rendering (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.