# 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.

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

`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](https://zerocaptcha.io/guides/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.webdriver` flag, which is `true` in an automated browser;
- the user agent, which says `HeadlessChrome` in headless mode;
- `window.chrome` and `chrome.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`:

```js
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](https://zerocaptcha.io/guides/submit-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](https://zerocaptcha.io/guides/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](https://zerocaptcha.io/guides/cloudflare-challenge-vs-turnstile)
and [Cloudflare Turnstile in headless browsers](https://zerocaptcha.io/blog/cloudflare-turnstile-headless-browser).

## Sources

- [puppeteer-extra-plugin-stealth README](https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth),
  for its evasions and the "cat and mouse" caveat (checked 1 October 2026).
- npm registry: `puppeteer-extra-plugin-stealth` 2.11.2, published 1 March 2023, and `puppeteer`
  25.12.0, which needs Node.js 22.12.0 or later (checked 1 October 2026).
- [Cloudflare Turnstile overview](https://developers.cloudflare.com/turnstile/),
  [testing](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) 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).
- [Cloudflare Turnstile: client-side rendering](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/)
  (checked 1 October 2026).

## Questions

### Does puppeteer-extra-plugin-stealth bypass Cloudflare Turnstile?

Sometimes the widget passes in a stealthy browser, often it does not. The plugin hides common automation signals, but Turnstile also weighs signals the plugin does not control, such as the network the browser runs from.

### Is puppeteer-extra-plugin-stealth still maintained?

Its latest npm release, 2.11.2, was published in March 2023 (checked 1 October 2026). Puppeteer and Chrome have had many releases since.

### What is the reliable way to handle Cloudflare Turnstile in Puppeteer?

Let the widget try, and if it has not produced a token within a few seconds, get one from a solving API and write it into the cf-turnstile-response input before submitting.
