Your Puppeteer script reaches a login form with Cloudflare's Turnstile box under the password field, and stops. The box spins, says "Verification failed", spins again. The hidden field it should fill stays empty, the form posts without a token, and the server answers 403.
The box isn't what the server checks. When Turnstile passes, it writes a token into a hidden cf-turnstile-response input, and the site's backend asks Cloudflare whether that token is good. A solving API returns the same kind of token from the sitekey and the page URL. So: read the sitekey, get a token, put it where the form reads it, submit.
I ran it all against ScrapingCourse's Turnstile login, a practice page for scraping exercises with its own demo login (admin@example.com / password). It uses a real sitekey, not a Cloudflare test key, and its server checks the token. Node 22.18.0, puppeteer-core 25.14.0, Chrome 155. Every output below is from runs on 2026-10-18, including one that failed even though its token was fine.
Widget or challenge page?
"Verify you are human" shows up in two places, and they need different fixes. The practice site has both:
$ grab() { curl -s -A "$UA" -D - "$1" | tr -d '\r' | grep -oiE '^HTTP/.*|^cf-mitigated:.*|<title>[^<]*</title>'; }
$ grab https://www.scrapingcourse.com/login/cf-turnstile
HTTP/1.1 200 OK
<title>Cloudflare Login Challenge to Learn Web Scraping - ScrapingCourse.com</title>
$ grab https://www.scrapingcourse.com/cloudflare-challenge
HTTP/1.1 403 Forbidden
Cf-Mitigated: challenge
<title>Just a moment...</title>
The first has a Turnstile widget in its form. The second is Cloudflare's challenge page: a 403 titled "Just a moment..." that showed "Performing security verification" and a "Verify you are human" checkbox in headless Chrome. No form takes a token there. It ends in a cf_clearance cookie, covered in this post on managed vs interactive challenges and this one on the cookie.
What the widget does in headless Puppeteer
First, no solver. I opened the page and read the hidden field every 5 seconds (trimmed):
00:25:03Z browser: Chrome/155.0.8059.79 | UA: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36
00:25:03Z navigator.webdriver: true
00:25:03Z widget: {"sitekey":"0x4AAAAAAAW9zqSPAlyOSMfO","callback":"javascriptCallback"}
00:25:03Z typeof window.javascriptCallback: undefined
00:25:34Z t=30s {"box":"195x71","iframesInDocument":0,"openShadowRoot":false,"field":"0 chars","cloudflareFrames":1,"widgetText":""}
Screenshots show "Verification failed" at 5 and 10 seconds, "Verifying..." at 15 (a retry), then "Verification failed" to the end. No checkbox, ever. A headed window from the same script did exactly the same.
Three details matter for the script:
-
iframesInDocument: 0: the widget's iframe sits in a closed shadow root. You won't need it. - The hidden input exists from the start (
0 chars, not missing). Turnstile adds it to the form. -
data-callback="javascriptCallback"names a function this page never defines.
Step 1: read the widget's settings
A widget rendered from HTML is a div.cf-turnstile with its settings in data-* attributes:
| Attribute | On this page | What to do with it |
|---|---|---|
data-sitekey |
0x4AAAAAAAW9zqSPAlyOSMfO |
send it as sitekey
|
data-action |
not set | if set, send it as action
|
data-cdata |
not set | the API reference has no field for it |
data-callback |
javascriptCallback |
call it after filling the field, if it exists |
A sitekey starting with 1x, 2x or 3x is one of Cloudflare's test keys. Those only produce the dummy token XXXX.DUMMY.TOKEN.XXXX, which production secret keys reject, so there's nothing to solve.
Step 2: get a token from the API
A Turnstile task on the 2Captcha-style in.php / res.php API takes method=turnstile, the sitekey, the full pageurl and json=1 (Turnstile guide). The API reference adds action, for widgets that set one. Poll res.php every 5 seconds, starting at 10. No dependencies:
// turnstile.mjs: get a Cloudflare Turnstile token from a 2Captcha-style in.php / res.php API.
// Node 18+ (built-in fetch), no dependencies. Set CAPTCHAAI_KEY in the environment.
const API = "https://ocr.captchaai.com";
const KEY = (process.env.CAPTCHAAI_KEY ?? "").trim();
const RETRY = new Set(["ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR", "NETWORK_ERROR"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// One request. Resolves to { ok, text } for JSON replies, plain-text replies and network errors alike.
async function call(path, params) {
try {
const r = await fetch(`${API}/${path}`, {
method: "POST",
body: new URLSearchParams({ ...params, key: KEY, json: "1" }),
signal: AbortSignal.timeout(30_000),
});
const body = await r.text();
try {
const reply = JSON.parse(body);
return { ok: reply.status === 1, text: String(reply.request) }; // the task id is a number
} catch {
return { ok: false, text: body.trim() || "EMPTY_REPLY" };
}
} catch {
return { ok: false, text: "NETWORK_ERROR" };
}
}
async function submit(task) {
for (let attempt = 0; ; attempt++) {
const r = await call("in.php", task);
if (r.ok) return r.text;
// ERROR_BAD_PARAMETERS, ERROR_WRONG_SITEKEY, a bad key...: the request is wrong, retrying won't help
if (!RETRY.has(r.text) || attempt === 2) throw new Error(`in.php: ${r.text}`);
await sleep(5000 * 2 ** attempt);
}
}
export async function solveTurnstile({ sitekey, pageurl, action, log = () => {}, timeout = 120 }) {
if (!KEY) throw new Error("Set CAPTCHAAI_KEY to your API key first");
if (!sitekey || !pageurl) throw new Error("sitekey and pageurl are both required");
const task = { method: "turnstile", sitekey, pageurl, ...(action ? { action } : {}) };
for (let tries = 1; tries <= 2; tries++) {
const started = Date.now();
const id = await submit(task);
log(`task ${id} submitted`);
await sleep(10_000); // nothing is ready before ~10 s
let last = "";
while (Date.now() - started < timeout * 1000) {
const r = await call("res.php", { action: "get", id });
const secs = ((Date.now() - started) / 1000).toFixed(1);
if (r.ok) {
log(`task ${id} solved in ${secs}s`);
return r.text;
}
last = `${r.text} after ${secs}s`;
if (r.text === "ERROR_CAPTCHA_UNSOLVABLE") break; // this task failed, a new one may not
if (r.text !== "CAPCHA_NOT_READY" && !RETRY.has(r.text)) throw new Error(`res.php: ${r.text}`);
await sleep(5000);
}
log(`task ${id}: ${last}`);
}
throw new Error("no token from 2 tasks");
}
Step 3: fill the field and submit in one step
Cloudflare's rendering docs say it plainly: "An invisible input with the name cf-turnstile-response is added and will be sent to the server with the other fields." The token goes there.
My first version filled the field, typed the email and password, and clicked Submit. It passed nine of ten runs. The one that failed got a 403 with a real token:
00:45:31Z task 5608680262 solved in 47.7s
00:45:31Z token: 1.F9jwQW7KDG... (709 chars)
00:45:31Z injected: {"created":false,"callback":"javascriptCallback is undefined"}
00:45:32Z HTTP 403 https://www.scrapingcourse.com/login/cf-turnstile | Forbidden | 0 products
The token was fine. The field wasn't. The widget keeps retrying in the background, and each retry empties cf-turnstile-response. I put a marker in the field and refilled it whenever it changed: in two minutes the widget emptied it 17 times, in pairs about 13 seconds apart. Typing two fields and clicking takes about a second, so now and then a retry lands in that gap.
To check, I did it on purpose with one real token. A: fill the field, wait until the widget empties it, click. B: reload, then fill and submit in a single in-page call. A never sent the token, so B was its first use:
01:05:21Z task 5608863527 solved in 21.2s
01:05:21Z A: field filled, 709 chars
01:05:27Z A: 6.1s later the field holds 0 chars, clicking Submit
01:05:28Z A: HTTP 403 https://www.scrapingcourse.com/login/cf-turnstile | Forbidden
01:05:29Z B: HTTP 200 https://www.scrapingcourse.com/dashboard | Success Page - ScrapingCourse.com
So the final script types the credentials, then sets the field and submits inside one page.evaluate(). The widget can't run in the middle of that function.
The callback goes into the same call. On many sites it enables the submit button or sends the form, so call it when it exists. Here it's declared but never defined, so check typeof first.
The whole script
// login-puppeteer.mjs: solve the Turnstile widget on a practice login page and log in with Puppeteer.
// Needs: Node 22.12+, npm i puppeteer-core@25.14.0, Chrome installed, turnstile.mjs next to this file.
// CAPTCHAAI_KEY=... node login-puppeteer.mjs solve, fill, submit
// node login-puppeteer.mjs <token> skip the solve, use this token
import puppeteer from "puppeteer-core";
import { solveTurnstile } from "./turnstile.mjs";
const PAGE = "https://www.scrapingcourse.com/login/cf-turnstile";
const log = (...a) => console.log(new Date().toISOString().slice(11, 19) + "Z", ...a);
const browser = await puppeteer.launch({ channel: "chrome", headless: true });
try {
const page = await browser.newPage();
await page.goto(PAGE, { waitUntil: "domcontentloaded" });
// 1. The widget's settings, straight from its data-* attributes
const widget = await page.$eval(".cf-turnstile", (el) => ({
sitekey: el.dataset.sitekey,
action: el.dataset.action ?? null,
cdata: el.dataset.cdata ?? null, // read it to know it's there: the API reference has no field for it
callback: el.dataset.callback ?? null,
}));
log("widget:", JSON.stringify(widget));
// 2. A token for that sitekey on this URL (or the one from the command line)
const token = process.argv[2] ??
await solveTurnstile({ sitekey: widget.sitekey, pageurl: page.url(), action: widget.action, log });
log(`token: ${token.slice(0, 12)}... (${token.length} chars)`);
// 3. The demo credentials the page shows
await page.type("#email", "admin@example.com");
await page.type("#password", "password");
// 4. Fill the hidden field and submit in one step: the widget keeps retrying in the
// background, and each retry empties that field
await page.waitForSelector('input[name="cf-turnstile-response"]', { timeout: 15_000 }).catch(() => {});
const [response, filled] = await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.evaluate((token, callback) => {
const form = document.querySelector(".cf-turnstile").closest("form");
let field = form.querySelector('input[name="cf-turnstile-response"]');
const created = !field; // the widget never added its field, so add one
if (created) {
field = Object.assign(document.createElement("input"), { type: "hidden", name: "cf-turnstile-response" });
form.append(field);
}
field.value = token;
const fn = callback ? window[callback] : undefined;
if (typeof fn === "function") fn(token); // only if the page really defines it
form.requestSubmit();
return { created, callback: callback ? `${callback} is ${typeof fn}` : "none" };
}, token, widget.callback),
]);
log("filled and submitted:", JSON.stringify(filled));
const products = await page.$$eval(".product-item .product-name", (els) => els.map((e) => e.textContent));
log(`HTTP ${response.status()} ${page.url()} | ${await page.title()} | ${products.length} products`,
products.length ? `(first: ${products[0]})` : "");
} finally {
await browser.close();
}
Install with npm i puppeteer-core@25.14.0 (Node 22.12 or newer). channel: "chrome" launches your installed Chrome. With the full puppeteer package you can drop it and use the browser it downloads. A run where the solve took 47.6 seconds, like the one that failed before:
$ CAPTCHAAI_KEY=... node login-puppeteer.mjs
00:53:47Z widget: {"sitekey":"0x4AAAAAAAW9zqSPAlyOSMfO","action":null,"cdata":null,"callback":"javascriptCallback"}
00:53:47Z task 5608761828 submitted
00:54:34Z task 5608761828 solved in 47.6s
00:54:34Z token: 1.CaI_ZUyVXs... (709 chars)
00:54:35Z filled and submitted: {"created":false,"callback":"javascriptCallback is undefined"}
00:54:36Z HTTP 200 https://www.scrapingcourse.com/dashboard | Success Page - ScrapingCourse.com | 12 products (first: Chaz Kangeroo Hoodie)
All five runs of this version logged in. The 25 solves in my logs took 16 to 48 seconds: 21 landed on the 16-, 21- or 26-second poll, one on 32 and three around 48, so keep the 120-second timeout. A token as the first argument skips the solve and tests the page side for free (not-a-real-token gets a 403). The Playwright version, in Python, is in this earlier post.
How long a token lasts, and how often it works
From Cloudflare's server-side validation docs: "Each token is valid for 300 seconds (5 minutes) after generation", and each one "can only be validated once". I tested both on this server, every login in a fresh session:
# one token, two logins
00:56:42Z first use: HTTP 200 https://www.scrapingcourse.com/dashboard | Success Page - ScrapingCourse.com | 12 products
00:56:43Z same token, new session: HTTP 200 https://www.scrapingcourse.com/dashboard | Success Page - ScrapingCourse.com | 12 products
# two tokens, each held before its only login
01:01:17Z used 240s after it arrived: HTTP 200 https://www.scrapingcourse.com/dashboard | Success Page - ScrapingCourse.com | 12 products
01:02:29Z used 310s after it arrived: HTTP 403 https://www.scrapingcourse.com/login/cf-turnstile | Forbidden | 0 products
The age limit held: 240 seconds passed, 310 didn't. Single use didn't: the same token logged in twice, in both runs. I don't know how this server validates, but a random 709-character string and the dummy token both got 403. A server that calls siteverify as Cloudflare documents refuses a second use (timeout-or-duplicate), so use one token per submit, within a few minutes of the solve. If a fresh token still gets refused, this post on Turnstile 403s covers the other causes.
This form doesn't need a browser
The same login works with Node's fetch alone: GET the page, keep its cookies, read _token and the sitekey from the HTML, solve, POST. It worked first time. fetch keeps no cookies, and the login redirects to a dashboard that checks the session cookie, so follow redirects yourself and send the cookies on each hop. The status code tells you which check failed. The same POST with a fake token:
00:38:56Z cookies + _token: HTTP 403 | Forbidden
00:38:56Z _token, no cookies: HTTP 419 | Page Expired
00:38:56Z cookies, no _token: HTTP 419 | Page Expired
419 means the session is wrong, and the Turnstile check never ran. 403 is the Turnstile check.
When the API says no: ERROR_WRONG_SITEKEY and ERROR_CAPTCHA_UNSOLVABLE
The docs list ERROR_WRONG_SITEKEY for a sitekey that is "blank or malformed". That's not quite what came back. Raw in.php replies, then how the accepted tasks ended (trimmed):
00:31:43Z empty sitekey: in.php -> {"status":0,"request":"ERROR_BAD_PARAMETERS"}
00:31:43Z sitekey 'abc': in.php -> {"status":1,"request":5608561322}
00:31:43Z no pageurl: in.php -> {"status":0,"request":"ERROR_BAD_PARAMETERS"}
00:31:44Z real sitekey, wrong page URL: in.php -> {"status":1,"request":5608561373}
00:32:54Z sitekey 'abc': task 5608561322 at 70.4s -> {"status":0,"request":"ERROR_CAPTCHA_UNSOLVABLE"}
00:33:05Z real sitekey, wrong page URL: task 5608561373 at 80.8s -> {"status":0,"request":"ERROR_CAPTCHA_UNSOLVABLE"}
A missing field fails at once. A bad sitekey, or the right one on the wrong page URL, gets a task id and fails later, here after 70 and 81 seconds. The URL matters because a sitekey only works on the hostnames its owner allowed (error 110200, "Domain not authorized", in Cloudflare's list).
ERROR_WRONG_SITEKEY did show up once, from res.php, for the sitekey "undefined". That's what URLSearchParams sends when the value is undefined, say on a page with no data-sitekey:
01:14:10Z form body: method=turnstile&sitekey=undefined&pageurl=...&json=1
01:14:11Z in.php -> {"status":1,"request":5608945475}
01:14:26Z task 5608945475 at 15.6s -> {"status":0,"request":"ERROR_WRONG_SITEKEY"}
On a second try the same input came back ERROR_CAPTCHA_UNSOLVABLE after 55 seconds. So the helper refuses a missing sitekey before sending anything, stops on ERROR_BAD_PARAMETERS or ERROR_WRONG_SITEKEY from either endpoint, and gives ERROR_CAPTCHA_UNSOLVABLE one more task. Fed a reCAPTCHA sitekey, it failed twice and threw after 105 seconds. When both tasks fail, check the sitekey and the URL first.
What can still go wrong
-
There's no
data-sitekey. The site callsturnstile.render(el, { sitekey: ... })from its own script. Find that call and take the sitekey, and anyaction, from there. -
The widget isn't inside a form. The site reads the token with
turnstile.getResponse()or in its callback and posts it withfetch, so filling an input does nothing. Call the callback with the token, or send the request the site's script would send. - The token is accepted and you're still blocked. The token is one check. It does nothing for your IP's reputation, request rate or the site's other bot checks.
FAQ
Can Puppeteer pass Turnstile without a solver?
Not in my runs. Headless and headed, the widget failed within 5 seconds and was still failing at 30.
How long is a token valid?
300 seconds, per Cloudflare. Here, 240 passed and 310 failed.
One thing I couldn't test: a widget with data-cdata. If you've seen one accept or reject a solved token, I'd like to hear which.
Disclosure: I run CaptchaAI, the API these examples call. This article was written by an AI agent, and every output above is from a real run on 2026-10-18.
— Bassem
Top comments (0)