CaptchaNo
Back to blog
tutorials18 Oct 2026·6 min read

Solve Cloudflare Turnstile in Puppeteer (Node.js)

DDishant Singh
tutorials

A complete Puppeteer script to solve Cloudflare Turnstile: read the widget, get a token, match the user agent, inject it and fire the callback.

To solve Cloudflare Turnstile in Puppeteer, read the widget's sitekey (and its action and cData, if set) from the page, get a token for them from a solving API, switch the page to the user agent the token was produced with, put the token in the hidden cf-turnstile-response field, and call the widget's callback. Skip the callback and the field fills but the page never reacts — the most common reason Turnstile tutorials "work" on a demo and fail on real forms. Below is a complete Node.js script and the reasoning for each step. The short version lives on our Puppeteer integration page.

Before you start

You need Node.js 18 or newer, npm i puppeteer, a solving API key, and permission to automate the site. If the site is yours, don't solve your own widget in tests — Cloudflare publishes an always-pass test sitekey, covered at the end.

Three facts from Cloudflare's documentation drive the approach:

  • Inside a form, the widget adds a hidden input named cf-turnstile-response that carries the token on submit (client-side rendering docs).
  • Pages can react through a data-callback function (or the callback option of turnstile.render) that receives the token.
  • Tokens are valid for 300 seconds and verify only once; the server rejects old or reused ones with timeout-or-duplicate (server-side validation docs).

Step 1: Read the widget's parameters

Read everything the widget was rendered with, not just the sitekey. Pages can scope a widget with an action and a cData value, and a token produced without them may fail the site's checks.

const widget = await page.$eval('.cf-turnstile[data-sitekey]', (el) => ({
  sitekey: el.getAttribute('data-sitekey'),
  action: el.getAttribute('data-action'),
  cData: el.getAttribute('data-cdata'),
  callback: el.getAttribute('data-callback'),
}));

If the page renders the widget with JavaScript (turnstile.render) instead of a .cf-turnstile element, the sitekey is in that call's options in the page's own script; search the page source for render(.

Step 2: Request the token

Send the sitekey, page URL and any action or cData to the solving API and wait for the token. With CaptchaNo, POST /v1/solve?wait=60 returns the token plus the user agent it was produced with.

const CAPTCHANO_KEY = 'cn-YOUR_API_KEY';
const PROXY = 'http://user:pass@host:port'; // the proxy the browser uses

async function solveTurnstile({ sitekey, action, cData }, url) {
  const res = await fetch('https://api.captchano.com/v1/solve?wait=60', {
    method: 'POST',
    headers: { Authorization: `Bearer ${CAPTCHANO_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      type: 'turnstile',
      url,
      sitekey,
      ...(action && { action }),
      ...(cData && { cData }),
      proxy: PROXY,
    }),
  });
  const data = await res.json();
  if (data.status !== 'ready') throw new Error(JSON.stringify(data.error ?? data));
  return data.solution; // { token, userAgent }
}

Passing your proxy means the token is produced from the same exit IP your browser uses. The request shape and error codes are in the API reference.

Step 3: Match the user agent, inject, and fire the callback

Turnstile tokens are tied to the user agent they were produced with, so switch the page to that user agent before using the token, then set the field and call the callback.

if (solution.userAgent) await page.setUserAgent(solution.userAgent);

await page.evaluate(({ token, callback }) => {
  document.querySelectorAll('input[name="cf-turnstile-response"]').forEach((el) => { el.value = token; });
  if (callback && typeof window[callback] === 'function') window[callback](token);
}, { token: solution.token, callback: widget.callback });

Single-page apps often use a wrapper such as react-turnstile, which passes the token to an onSuccess prop rather than a global function. There, setting the input does nothing on its own; find the function the app passes as the callback and drive the form through the app's own flow, or use the app's API directly if you are authorised to.

Step 4: The full script

Solve immediately before submitting so the five-minute window isn't spent on navigation.

import puppeteer from 'puppeteer';

const TARGET = 'https://example.com/signup';

const browser = await puppeteer.launch({ args: ['--proxy-server=http://host:port'] });
const page = await browser.newPage();
await page.authenticate({ username: 'user', password: 'pass' });
await page.goto(TARGET, { waitUntil: 'networkidle2' });

const widget = await page.$eval('.cf-turnstile[data-sitekey]', (el) => ({
  sitekey: el.getAttribute('data-sitekey'),
  action: el.getAttribute('data-action'),
  cData: el.getAttribute('data-cdata'),
  callback: el.getAttribute('data-callback'),
}));

// ... fill the rest of the form here ...

const solution = await solveTurnstile(widget, page.url());
if (solution.userAgent) await page.setUserAgent(solution.userAgent);
await page.evaluate(({ token, callback }) => {
  document.querySelectorAll('input[name="cf-turnstile-response"]').forEach((el) => { el.value = token; });
  if (callback && typeof window[callback] === 'function') window[callback](token);
}, { token: solution.token, callback: widget.callback });

await Promise.all([page.waitForNavigation(), page.click('button[type="submit"]')]);
console.log(page.url()); // check the page you actually landed on
await browser.close();

Verify the outcome the site shows — a redirect, a success message, a new cookie — rather than logging "solved" after the click.

When the token is still rejected

SymptomCauseFix
Field filled, nothing happensPage waits for its callbackCall the data-callback function, or the app's onSuccess handler
Rejected only from serversToken produced on another IPPass the browser's proxy to the solve
Rejected after a slow stepToken older than 300 secondsSolve right before submitting; re-solve on retry
Rejected though everything matchesWidget action or cData not sentRead data-action and data-cdata and include them

The broader version of this checklist is in why captcha tokens get rejected.

Full-page challenges are a different problem

This guide covers the Turnstile widget embedded in a form. If Puppeteer lands on Cloudflare's full-page "Verifying you are human" screen instead, the site is challenging the whole request, and what you need is a session Cloudflare will clear — persistent cookies, a stable user agent and IP, a sane request rate. Challenge pages also pass extra values to the widget, which our Turnstile solver accepts as action, cData and pageData.

Testing your own forms

For sites you own, use Cloudflare's test credentials instead of solving in CI. The E2E testing tutorial lists an always-pass test sitekey and secret; swap them in per environment and your Puppeteer suite tests your form logic without a live challenge. Never ship them to production.

Frequently Asked Questions

Can Puppeteer pass Cloudflare Turnstile on its own?

Sometimes, in managed mode, when the browser looks entirely normal, but automated browsers are often the sessions Turnstile chooses to challenge. Clicking the checkbox from a script rarely works reliably. For automation you are authorised to run, requesting a token from a solving API and injecting it is the dependable approach.

Where is the Turnstile sitekey on the page?

In the data-sitekey attribute of the element with the cf-turnstile class, alongside optional data-action, data-cdata and data-callback attributes. When the widget is rendered with JavaScript instead, the sitekey is in the options passed to turnstile.render in the page's own script.

How long is a Cloudflare Turnstile token valid?

Three hundred seconds, according to Cloudflare's server-side validation documentation, and each token can be validated only once. After that the site's server receives a timeout-or-duplicate error. Request the token immediately before you submit, and request a new one for every retry.

Why does the Turnstile token need the same user agent?

Turnstile evaluates the browser environment that produced the token, and the user agent is part of it. Submitting the token from a browser reporting a different user agent makes the session inconsistent. Set Puppeteer's user agent to the one returned with the token before you inject it and submit.

Does this work for Cloudflare's full-page challenge?

Not by itself. The full-page "Verifying you are human" screen challenges the whole request and grants a clearance cookie tied to the browser that passed it. Embedded widget tokens and full-page clearance are different mechanisms, so treat a full-page challenge as a session problem first.

Try it on your own target

Start a free CaptchaNo trial, create a key in the dashboard and drop it into the script. If you are choosing between widgets for your own site, see Turnstile vs reCAPTCHA.