Solve Cloudflare Turnstile in Puppeteer (Node.js)
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-responsethat carries the token on submit (client-side rendering docs). - Pages can react through a
data-callbackfunction (or thecallbackoption ofturnstile.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
| Symptom | Cause | Fix |
|---|---|---|
| Field filled, nothing happens | Page waits for its callback | Call the data-callback function, or the app's onSuccess handler |
| Rejected only from servers | Token produced on another IP | Pass the browser's proxy to the solve |
| Rejected after a slow step | Token older than 300 seconds | Solve right before submitting; re-solve on retry |
| Rejected though everything matches | Widget action or cData not sent | Read 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.