Puppeteer Turnstile bypass: test sitekeys and token injection
If the page is yours, do not bypass anything — swap in one of Cloudflare's published test sitekeys and the widget hands your Puppeteer script a dummy token. If you cannot change the page, the only reliable path is token injection: read data-sitekey, get a token out of band, write it into the cf-turnstile-response input in the parent document, fire the widget callback, submit.
Start with Cloudflare's test sitekeys
Most "Puppeteer Turnstile bypass" problems are really configuration problems. If you control the page — staging, CI fixtures, a QA suite, an uptime probe — Cloudflare publishes sitekeys with fixed outcomes. They work on any domain, including localhost, and they produce a dummy token formatted XXXX.DUMMY.TOKEN.XXXX.
| Test sitekey | Behaviour | Widget type |
|---|---|---|
| 1x00000000000000000000AA | Always passes | Visible |
| 2x00000000000000000000AB | Always fails | Visible |
| 1x00000000000000000000BB | Always passes | Invisible |
| 2x00000000000000000000BB | Always fails | Invisible |
| 3x00000000000000000000FF | Forces an interactive challenge | Visible |
Pair them with the matching test secret keys server-side: 1x0000000000000000000000000000000AA always passes validation, 2x0000000000000000000000000000000AA always fails, and 3x0000000000000000000000000000000AA returns a "token already spent" error — useful for exercising your replay-handling branch. Test secret keys only validate dummy tokens and production secret keys reject them, so the two environments cannot bleed into each other.
The failure sitekeys matter as much as the passing one. A test suite that only ever sees a green widget never proves your server rejects a bad token. Here is a self-contained script: it serves a form on localhost, drives it with Puppeteer, and waits for the hidden field to hold a value.
// turnstile-testkey.mjs — npm i puppeteer && node turnstile-testkey.mjs import http from 'node:http'; import puppeteer from 'puppeteer'; const PAGE = `<!doctype html><meta charset="utf-8"> <form id="f" action="/submit" method="POST"> <div class="cf-turnstile" data-sitekey="1x00000000000000000000AA" data-callback="onToken"></div> <button type="submit">Send</button> </form> <script>function onToken(t) { window.__token = t; }</script> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>`; const server = http.createServer((_req, res) => { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(PAGE); }); await new Promise(resolve => server.listen(3000, resolve)); const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('http://localhost:3000', { waitUntil: 'domcontentloaded' }); // waitForSelector only proves the node exists. Wait for it to hold a value. await page.waitForFunction( () => document.querySelector('[name="cf-turnstile-response"]')?.value?.length > 0, { timeout: 30_000 } ); const token = await page.$eval('[name="cf-turnstile-response"]', el => el.value); console.log(token); // XXXX.DUMMY.TOKEN.XXXX await page.click('button[type="submit"]'); await browser.close(); server.close();
Top-level await means this has to be an ES module — use the .mjs extension or set "type": "module". Test sitekeys stop being an option the moment the page is a production deployment you cannot reconfigure. That is the rest of this guide.
The widget is a cross-origin iframe. The input is not.
Cloudflare's own Content Security Policy guidance is the clearest statement of the architecture: Turnstile needs script-src https://challenges.cloudflare.com and frame-src https://challenges.cloudflare.com. The challenge UI renders inside an iframe served from a different origin. Puppeteer can see that frame — page.frames() returns it, and CDP will happily attach to it — but there is nothing stable in there to drive. The markup is Cloudflare's, obfuscated, and the verdict is decided server-side. Clicking a checkbox you found by traversing that frame does not make a token appear.
What you actually need lives in your own document. When the widget container sits inside a <form>, Turnstile injects a hidden input into that form: "An invisible input with the name cf-turnstile-response is added and will be sent to the server with the other fields." That input is in the parent document, same-origin with everything else on the page, and fully writable from page.evaluate.
Two configuration values change the target. response-field defaults to true — set to false, no input is created at all. response-field-name defaults to cf-turnstile-response but can be renamed. Read both off the container instead of hardcoding the selector, and create the input yourself when it is missing.
Point this at properties you own or are authorised to test — your staging environment, your CI suite, your uptime monitors, your own anti-bot configuration. Someone else's login form is not that.
Token injection in Puppeteer, end to end
The recipe is four steps: read the sitekey from the container, obtain a token for that sitekey and page URL, write it into the response field, then trip whatever the form is waiting on. Solve as late as possible — a Turnstile token is valid for 300 seconds and can only be validated once, so a token minted at the top of a long flow is dead by the time you submit.
// inject-token.mjs — npm i puppeteer // SOLVEGATE_KEY=sk_live_... TARGET_URL=https://staging.example.com/signup node inject-token.mjs import puppeteer from 'puppeteer'; const API_KEY = process.env.SOLVEGATE_KEY; const TARGET = process.env.TARGET_URL; async function solve({ sitekey, url, action }) { const res = await fetch('https://api.solvegate.io/v1/solve', { method: 'POST', headers: { authorization: `Bearer ${API_KEY}`, 'content-type': 'application/json', }, body: JSON.stringify({ gate: 'turnstile', sitekey, url, action }), }); const body = await res.json(); if (!res.ok || body.status !== 'solved') { throw new Error(`solve failed: ${body.error_code ?? res.status} ${body.error_message ?? ''}`); } if (body.mode === 'sandbox') { console.warn('sandbox key in use — this token will not clear a real gate'); } return body.token; } const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(TARGET, { waitUntil: 'domcontentloaded' }); // The container is in YOUR document. The challenge UI is in Cloudflare's iframe. const container = await page.waitForSelector('[data-sitekey]', { timeout: 30_000 }); const { sitekey, action, fieldName } = await container.evaluate(el => ({ sitekey: el.getAttribute('data-sitekey'), action: el.getAttribute('data-action') || undefined, fieldName: el.getAttribute('data-response-field-name') || 'cf-turnstile-response', })); const token = await solve({ sitekey, url: page.url(), action }); await page.evaluate((token, fieldName) => { let input = document.querySelector(`[name="${fieldName}"]`); if (!input) { // response-field="false", or the widget is not inside a form. input = document.createElement('input'); input.type = 'hidden'; input.name = fieldName; (document.querySelector('form') ?? document.body).appendChild(input); } input.value = token; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); // Implicit rendering: data-callback names a GLOBAL function. Most forms // keep the submit button disabled until it fires. const name = document.querySelector('[data-sitekey]')?.getAttribute('data-callback'); if (name && typeof window[name] === 'function') window[name](token); }, token, fieldName); await Promise.all([ page.waitForNavigation({ waitUntil: 'domcontentloaded' }).catch(() => {}), page.click('button[type="submit"]'), ]); console.log('submitted:', page.url()); await browser.close();
The callback step is the one people skip, and it is why "I set the value and nothing happened" is the most common failure. Setting input.value from script fires no events; React-style forms and hand-rolled validators both watch for them, and many pages gate the submit button on the Turnstile callback rather than on the field. Dispatch input and change, then call the callback.
Explicit rendering is the awkward case. turnstile.render(el, { callback }) usually receives a closure, not a named global, so there is nothing for page.evaluate to look up. Drive the form's own state instead: clear the disabled attribute on the submit control, or call the page's submit handler directly. Check window.turnstile.getResponse() in the console first — if it returns a value the widget already passed and you do not need any of this.
SolveGate handles the token side: POST /v1/solve with gate ("turnstile" or "waf"), sitekey and url, authenticated with a bearer secret key, typically returning a token in under 1.5 seconds. GET /v1/solve/{id} polls an async solve and is free. Credits are prepaid, from $0.40 per 1,000 down to $0.075 at volume, failed solves are never billed, and the first 1,000 solves are free. There are SDKs on npm and PyPI under solvegate (Node 18+, Python 3.9+) if you would rather not hand-roll the fetch.
What differs from Playwright
The strategy is identical in both. The mechanics differ mostly in how much waiting you write yourself.
| Task | Puppeteer | Playwright |
|---|---|---|
| Wait for the container | page.waitForSelector('[data-sitekey]') — returns ElementHandle | null, 30 000 ms default timeout | page.waitForSelector(), or a locator that auto-waits on use |
| Wait for the field to hold a token | page.waitForFunction(fn, { timeout }) — you write the predicate | page.waitForFunction(), or expect(locator).toHaveValue() |
| Read an attribute | page.$eval(sel, el => el.getAttribute('data-sitekey')) | page.getAttribute(sel, 'data-sitekey') |
| Run JS in the page | page.evaluate(fn, ...args) — args serialised, handles passable | page.evaluate(fn, arg) — single arg, so pack an object |
| Enumerate the challenge iframe | page.frames() / elementHandle.contentFrame() | page.frames() / page.frameLocator() |
| Element retry semantics | Query methods are one-shot: the handle can go stale if the widget re-renders | Locators re-resolve on each action |
Two practical consequences. First, Puppeteer's waitForSelector resolves the instant the node is attached, which for a Turnstile response field means the instant it is created and empty — always follow it with a waitForFunction on the value, as in the first script. Second, an ElementHandle grabbed before the widget refreshes points at a detached node; re-query rather than reuse if there is any delay between reading the sitekey and writing the token.
What breaks it
- **Expiry and replay.** Tokens are valid for 300 seconds and single-use. A replayed or stale token comes back from siteverify as
timeout-or-duplicate; a malformed one asinvalid-input-response. Solve immediately before submitting, and never cache tokens across test runs. - **The widget overwrites you.**
refresh-expireddefaults toautoandretrydefaults toautowith aretry-intervalof 8000 ms, so a live widget can write its own value into the field after you have written yours. Inject and submit in the same tick where you can. - **
actionandcDatamismatch.** Both are echoed back in the siteverify response asactionandcdata. If the server compares them against what it expects, a token solved without the page'sdata-actionfails validation even though it is otherwise good. Read the attribute and pass it through, as the script above does. - **
execution: "execute".** The default is to run on render. When a page setsexecutiontoexecute, nothing happens untilturnstile.execute()is called, so there is no widget lifecycle to hook until the page triggers it. - **
appearance: "interaction-only".** The container exists but stays hidden, so awaitForSelectoron a visible element times out. Query for[data-sitekey]in the DOM rather than waiting on visibility. - **It is not the widget at all.** A full-page Cloudflare interstitial is a different mechanism from an embedded widget, and Turnstile pre-clearance issues a persistent
cf_clearancecookie rather than a form field. Those are thegate: "waf"case, not token injection into a form.
Worth stating plainly: SolveGate covers Cloudflare Turnstile — managed, non-interactive and invisible — and Turnstile-backed WAF challenge pages. It does not solve reCAPTCHA, hCaptcha, GeeTest, FunCaptcha or AWS WAF. If your Puppeteer script is stuck on one of those, nothing on this page applies.
Common questions
No. The widget UI renders in an iframe served from challenges.cloudflare.com, a different origin from your page — that is why Cloudflare's CSP guidance requires frame-src https://challenges.cloudflare.com. Puppeteer can enumerate that frame, but the markup inside is Cloudflare's and the verdict is decided server-side. Synthetic clicks produce no token.
Assigning input.value fires no DOM events, and most forms gate the submit button on the Turnstile callback rather than on the field. Dispatch input and change with bubbles: true, then look up the global function named by data-callback and call it with the token.
No. Use Cloudflare's test sitekeys — 1x00000000000000000000AA always passes, 2x00000000000000000000AB always fails, 3x00000000000000000000FF forces an interactive challenge — with the matching test secret keys on the server. They work on any domain including localhost and cost nothing.
300 seconds from generation, and it can only be validated once. Solve as late in the flow as possible. A replayed or expired token returns timeout-or-duplicate from siteverify.
turnstile.render() typically takes a closure, so there is no named function to invoke from page.evaluate. Check window.turnstile.getResponse() first — it may already hold a token. Otherwise drive the form directly: remove the disabled attribute from the submit control or call the page's own submit handler.
No. SolveGate covers Cloudflare Turnstile (managed, non-interactive, invisible) and Turnstile WAF challenge pages only. reCAPTCHA, hCaptcha, GeeTest, FunCaptcha and AWS WAF are out of scope.
Related
More in Guides
Automating a gate you own or are authorised to test?
// SolveGate clears Cloudflare Turnstile and WAF challenges through one REST call · first 1,000 solves free