DEV Community

Harry Stone
Harry Stone

Posted on

Getting a Cloudflare Turnstile token in Puppeteer without breaking your flow

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);
Enter fullscreen mode Exit fullscreen mode

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); },
  });
});
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)