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:

  1. 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="…">.
  2. The widget creates a hidden <input name="cf-turnstile-response"> and, on success, fills it and calls callback(token).
  3. 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

  1. The site is yours (QA / test automation). Cloudflare publishes test sitekeys — 1x00000000000000000000AA always passes. Point your staging environment at it and your flow tests what you actually care about. No API, no cost.
  2. 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.
  3. You only test your own backend. If you verify tokens server-side, issue them with the test key and hit your endpoint directly.
  4. 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.turnstile only appears after Cloudflare's script loads, and some pages call render in the same tick.
  • With implicit rendering there is no render call from the page: read data-sitekey, data-action, data-cdata and the data-callback function name from the .cf-turnstile element instead.
  • Letting the original render run means the hidden cf-turnstile-response input exists in the DOM even if the widget itself fails — you will need it as a fallback.
  • window.__tsParams.sitekey answers "where do I get the sitekey": it is the exact value the page itself uses. Some sites use per-page keys, so reading data-sitekey from 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/change matters because some frontends validate on change, not on submit.
  • Don't fire a synthetic submit blindly (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 render call). Read data-sitekey and data-callback from the .cf-turnstile element.
  • Works headful, fails headless → fingerprinting, not the token. Go headful or stealth.
  • CAPCHA_NOT_READY for a long time → service load or a wrong pageurl/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. Wrap turnstile.render via evaluateOnNewDocument; capture sitekey, 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

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.