Cloudflare Turnstile has quietly become the default "are you a robot?" gate on new sites — and it is where most Puppeteer flows die. Unlike reCAPTCHA, it does not expose a global configuration object you can poke; the token it produces is single-use and short-lived; and the widget fingerprints the browser hard enough that a plain headless launch often fails before a human would ever see a challenge.
This post walks the whole pipeline: where to get the sitekey, how to capture the page's callback, how to fetch a valid token from a solving API, and how to hand it back without breaking your flow. Code is Node.js + Puppeteer. Heads-up: the snippets were checked against a local mock of the solving API and a stub Turnstile widget, not against a live Cloudflare-protected site — treat them as a well-commented starting point and test against a test sitekey first.
Why the reCAPTCHA trick does not work for Turnstile
If you automated reCAPTCHA v2 before, you probably injected the token through the global config:
await page.evaluate((token) => {
for (const client of window.___grecaptcha_cfg.clients) {
if (typeof client.callback === 'function') client.callback(token);
}
}, token);
That works because reCAPTCHA keeps its per-page configuration in a global object (___grecaptcha_cfg). Turnstile has no such global you can rely on: the widget is rendered per container, and the callback only exists as an option passed to turnstile.render() — after that it lives in a closure you cannot reach.
That is exactly the dead end in this Stack Overflow thread: the author got a token from a solving service, set it on cf-turnstile-response, and then had no idea how to trigger the site's validation, because the page has no visible form — it just waits. The top answers there (set the hidden input's .value; fire a synthetic submit) work in some setups and miss the main path in others.
The reliable move: intercept turnstile.render before the page's own scripts run, capture the parameters (sitekey, action, cData) and the callback, and later call that callback with a token you got from elsewhere. That is precisely what the site's code would have called had the widget succeeded on its own.
How a Turnstile token gets into the page
In a normal human flow, three things happen:
- The page calls
turnstile.render(container, { sitekey, callback, 'error-callback', action, cData })— or, with implicit rendering, puts the same options on a<div class="cf-turnstile" data-sitekey="…" data-callback="…">. - The widget creates a hidden
<input name="cf-turnstile-response">and, on success, fills it and callscallback(token). - The token is sent to the server (via the callback's logic or a form submit) and verified once.
Two consequences for automation:
- Tokens are single-use and expire quickly. The server verifies them; a token you solved ten minutes ago — or already submitted — will not pass a second time. Solve fresh, inject immediately, never cache.
- The browser and the connection are fingerprinted. The widget inspects browser signals such as WebGL output, and Cloudflare scores the connection itself (TLS fingerprints such as JA3); there is a long Hacker News discussion about headless and privacy browsers getting blocked. In practice: a flagged headless Chromium may never produce a token on its own, and even after you inject a good token, a later re-challenge may fail. Decide early whether you need headful or stealth mode — that is a separate problem from token injection.
Step 0 — when you do not need a solving API at all
-
The site is yours (QA / test automation). Cloudflare publishes test sitekeys —
1x00000000000000000000AAalways passes. Point your staging environment at it and your flow tests what you actually care about. No API, no cost. - A real headful browser just passes. Managed challenges often solve themselves in a normal headful session without a click. If your flow tolerates that, it beats any API.
- You only test your own backend. If you verify tokens server-side, issue them with the test key and hit your endpoint directly.
- The site's rules allow it. A token API solves the captcha, not the terms of service — check the site's ToS/robots expectations before automating a third-party property at scale.
A token API earns its keep when the site belongs to someone else, you run headless at scale, and you cannot wait for a human.
Step 1 — capture sitekey, action, cData and the callback
page.evaluateOnNewDocument runs before the page's own JavaScript — the right moment to wrap turnstile.render. A property trap catches the moment Cloudflare's script assigns window.turnstile, so even a page that renders immediately on load goes through the wrapper:
await page.evaluateOnNewDocument(() => {
const wrap = (ts) => {
if (!ts || ts.__wrapped || typeof ts.render !== 'function') return ts;
const original = ts.render.bind(ts);
ts.render = (container, params = {}) => {
window.__tsParams = {
sitekey: params.sitekey,
action: params.action,
cData: params.cData,
};
window.__tsCallback = params.callback;
// Let the real widget render so the hidden input exists
return original(container, params);
};
ts.__wrapped = true;
return ts;
};
let current;
Object.defineProperty(window, 'turnstile', {
configurable: true,
get: () => current,
set: (value) => { current = wrap(value); },
});
});
Notes:
- The trap is needed because
window.turnstileonly appears after Cloudflare's script loads, and some pages callrenderin the same tick. - With implicit rendering there is no
rendercall from the page: readdata-sitekey,data-action,data-cdataand thedata-callbackfunction name from the.cf-turnstileelement instead. - Letting the original
renderrun means the hiddencf-turnstile-responseinput exists in the DOM even if the widget itself fails — you will need it as a fallback. -
window.__tsParams.sitekeyanswers "where do I get the sitekey": it is the exact value the page itself uses. Some sites use per-page keys, so readingdata-sitekeyfrom the raw HTML is a second-best check, not the source of truth.
Step 2 — get a token from a solving service
For sites you do not own, a token-solving API does the work of a human in a real browser: you send pageurl + sitekey (plus action, and the page's cData as data, if the page sets them) and receive a token valid for that page.
SolveCaptcha is one such service — hybrid: AI where it is reliable, human solvers where it is not — at $0.80 per 1,000 Turnstile tokens (rate checked 2026-10-19, solvecaptcha.com). Its API is two HTTP calls: submit to in.php, then poll res.php until the token is ready; there is also a solvecaptcha-javascript npm module that wraps the same flow.
const API_KEY = 'YOUR_API_KEY';
const API = 'https://api.solvecaptcha.com';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function solveTurnstileToken({ pageurl, sitekey, action, cData }) {
const params = new URLSearchParams({
key: API_KEY, method: 'turnstile', json: '1', pageurl, sitekey,
});
if (action) params.set('action', action);
if (cData) params.set('data', cData);
// With json=1 both endpoints answer {"status": 0|1, "request": "..."}
const submit = await (await fetch(`${API}/in.php?${params}`)).json();
if (submit.status !== 1) throw new Error(`in.php: ${submit.request}`);
for (;;) {
await sleep(5000); // documented poll interval
const res = await (await fetch(`${API}/res.php?` +
new URLSearchParams({ key: API_KEY, action: 'get', id: submit.request, json: '1' }))).json();
if (res.request === 'CAPCHA_NOT_READY') continue;
if (res.status === 1) return res.request; // the token
throw new Error(`res.php: ${res.request}`);
}
}
If you prefer not to hand-roll the polling, the solvecaptcha-javascript module exposes the same call as solver.cloudflareTurnstile({ pageurl, sitekey, action, data }).
Two honesty notes. First, no 100%-success promises: expect the occasional bad solve, so treat a page-level "verification failed" as a retryable event, not a crash. Second, full-screen Turnstile challenge pages (the interstitial variant, not an embedded widget) are a separate, harder case — check the service's documentation for that variant before committing.
Step 3 — inject the token without breaking the flow
Replay what the widget would have done. Prefer the callback; fall back to the hidden input:
await page.evaluate((token) => {
if (typeof window.__tsCallback === 'function') {
window.__tsCallback(token); // what the site expects on success
return;
}
const input = document.querySelector('input[name="cf-turnstile-response"]');
if (input) {
input.value = token;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}, token);
// If the flow continues via a real form/button:
// await page.click('#your-submit-button');
Why this order:
- Callback first. If the site wired a callback (typical for SPAs and "just wait" pages with no visible form), the callback is the only thing that moves the flow — this is the exact dead end from the Stack Overflow thread above.
-
Input + events covers sites that read the field on submit. Dispatching
input/changematters because some frontends validate on change, not on submit. -
Don't fire a synthetic
submitblindly (some SO answers do): on SPAs it can run handlers twice or too early. Click the real button or call the real handler.
What breaks in practice
- Token accepted once, then rejected → reused or expired token. Solve fresh per attempt; never reuse.
- Callback called, nothing happens → the page re-rendered the widget and now listens to a different callback. Reload and re-capture.
-
No sitekey captured → the page uses implicit rendering (no
rendercall). Readdata-sitekeyanddata-callbackfrom the.cf-turnstileelement. - Works headful, fails headless → fingerprinting, not the token. Go headful or stealth.
-
CAPCHA_NOT_READYfor a long time → service load or a wrongpageurl/sitekey. Back off and verify the parameters.
Two habits: send the exact pageurl your page is on (the token is bound to it), and do not poll faster than the documented interval — you only burn money.
When to stop and rethink
A token API is a bridge, not a moat. If you are re-challenged on every navigation, the problem is your fingerprint, your datacenter proxy, or the site's risk scoring — not the token. And for "login → Turnstile → dashboard" flows, a headful session with a warm cookie jar will often outlast a headless one.
TL;DR
- Turnstile has no
___grecaptcha_cfg. Wrapturnstile.renderviaevaluateOnNewDocument; capturesitekey,action,cData, and the callback. - Fetch a fresh token from a solving API with the exact
pageurl. - Inject via the captured callback; fall back to
cf-turnstile-response+ input/change events. - Tokens are single-use and short-lived: solve fresh, inject fast, retry politely.
If this saved you an afternoon, follow for more scraping-and-automation notes — and tell me in the comments how Turnstile is breaking your flow.
References
- How to inject a Cloudflare Turnstile token into Puppeteer — Stack Overflow
- SolveCaptcha API documentation (in.php / res.php, Turnstile section)
- SolveCaptcha pricing — Cloudflare Turnstile $0.8 per 1000, checked 2026-10-19
- solvecaptcha-javascript — Turnstile parameters and example
- Cloudflare Turnstile requiring fingerprintable WebGL — Hacker News
-
Cloudflare Turnstile widget configurations —
action,cData, callbacks - Cloudflare Turnstile testing — test sitekeys
AI disclosure: this article was drafted with AI assistance and reviewed by a human, in line with Dev.to's guidelines on disclosing AI assistance.
Top comments (0)