Playwright and Cloudflare Turnstile: handling the widget in E2E tests
Cloudflare publishes official test sitekeys, and they are the correct first answer: point your staging build at 1x00000000000000000000AA and the widget hands your Playwright test a dummy token instantly, every run. When you cannot swap the key, read data-sitekey off the page, fetch a real token from a solve API, inject it into the cf-turnstile-response input and fire the widget callback before you submit.
Why a Turnstile widget stalls a Playwright run
Turnstile mounts a cross-origin iframe from challenges.cloudflare.com and writes a hidden input named cf-turnstile-response into the surrounding form. Your test never sees an error. It sees a submit button that stays disabled, or a POST the server rejects because the response field is empty. The failure surfaces one step later, as a timeout on the next click or on the assertion after it.
You cannot drive the widget from the test. The challenge lives in a cross-origin frame, and in managed or invisible mode there is no checkbox to click — the token arrives on its own schedule or not at all.
CI makes this worse. Runner egress from GitHub Actions, GitLab, Hetzner or DigitalOcean carries a worse reputation score than your laptop, so a widget that resolved invisibly in local dev switches to a managed challenge in the pipeline. The same test, the same code, a different token latency — and any hardcoded wait you tuned locally is now wrong.
- The widget may render lazily, after your
gotoresolves. retry: autois the default, so a failed attempt re-renders the widget and clears the field you already read.- A token is valid for 300 seconds and can be redeemed once. Slow tests expire their own token.
First answer: Cloudflare's official test sitekeys
If you control the application under test, do not solve anything. Cloudflare ships dummy keys that work on every domain, including localhost, and return a token formatted XXXX.DUMMY.TOKEN.XXXX. Wire the sitekey and the secret key to environment variables, set the test values in your staging and CI config, and the widget resolves in milliseconds with no network dependency and no cost.
| Key | Type | Behaviour |
|---|---|---|
1x00000000000000000000AA | sitekey, visible | Always passes |
1x00000000000000000000BB | sitekey, invisible | Always passes |
2x00000000000000000000AB | sitekey, visible | Always fails |
3x00000000000000000000FF | sitekey, visible | Forces an interactive challenge |
1x0000000000000000000000000000000AA | secret | Always passes validation |
2x0000000000000000000000000000000AA | secret | Always fails validation |
3x0000000000000000000000000000000AA | secret | Returns timeout-or-duplicate |
Swap both halves. A test secret key only accepts the dummy token and rejects real ones, and a live secret key rejects the dummy token — a mismatched pair fails siteverify and you will chase the wrong bug. Use the failing keys deliberately to cover your error path, and 3x00000000000000000000FF to check that your interactive-challenge layout does not break the form.
If you cannot redeploy staging with a new key, rewrite it in the browser instead. Playwright can intercept the document response and patch the attribute before the widget script reads it:
// tests/fixtures/turnstile.js const ALWAYS_PASSES = '1x00000000000000000000AA'; /** Rewrite every data-sitekey in the HTML document to Cloudflare's test key. */ export async function stubTurnstile(page, urlPattern) { await page.route(urlPattern, async (route) => { if (route.request().resourceType() !== 'document') return route.continue(); const response = await route.fetch(); const body = (await response.text()).replace( /data-sitekey="[^"]*"/g, `data-sitekey="${ALWAYS_PASSES}"`, ); await route.fulfill({ response, body }); }); } // usage, before the first navigation: // await stubTurnstile(page, '**/login'); // await page.goto('https://staging.example.com/login');
This only helps if the server verifying the token also runs a test secret key. The browser half and the server half have to agree.
Which approach to pick
Read the table top down and stop at the first row you can actually implement. The two free rows cover most staging environments. The paid row exists for the case where the widget is a production key you are authorised to test and nobody will swap it for a QA build.
Solving a real widget from Playwright
Run this against properties you own or have written authorisation to test — your own staging login, your own uptime probe, your own anti-bot configuration. That boundary is the whole point of the workflow, not a footnote to it.
The mechanics are the same regardless of which service you call. Read data-sitekey and the current page URL off the DOM, POST them to the solver, wait for a token, then write that token into the response field and invoke the widget's success callback so the application's own JavaScript reacts — a React form that enables its submit button in data-callback will stay disabled if you only set input.value.
SolveGate takes POST /v1/solve with gate, sitekey and url, authenticated with a bearer secret key, and returns the token in about a second. Failed solves are not billed, and the first 1,000 solves are free.
// tests/login.spec.js import { test, expect } from '@playwright/test'; async function solveTurnstile(page) { const widget = page.locator('[data-sitekey]').first(); await expect(widget).toBeAttached({ timeout: 15_000 }); const sitekey = await widget.getAttribute('data-sitekey'); const action = await widget.getAttribute('data-action'); const callbackName = await widget.getAttribute('data-callback'); const fieldName = (await widget.getAttribute('data-response-field-name')) ?? 'cf-turnstile-response'; // Turnstile creates the hidden input at render time, before the token exists. await page.locator(`input[name="${fieldName}"]`).waitFor({ state: 'attached' }); const res = await fetch('https://api.solvegate.io/v1/solve', { method: 'POST', headers: { authorization: `Bearer ${process.env.SOLVEGATE_KEY}`, 'content-type': 'application/json', }, body: JSON.stringify({ gate: 'turnstile', sitekey, url: page.url(), action }), }); if (!res.ok) throw new Error(`solve failed: ${res.status} ${await res.text()}`); const { token, solve_ms } = await res.json(); console.log(`turnstile token in ${solve_ms}ms`); await page.evaluate( ({ token, fieldName, callbackName }) => { for (const input of document.querySelectorAll(`input[name="${fieldName}"]`)) { input.value = token; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } const cb = callbackName && window[callbackName]; if (typeof cb === 'function') cb(token); }, { token, fieldName, callbackName }, ); return token; } test('signs in through the Turnstile-protected form', async ({ page }) => { await page.goto('https://staging.example.com/login'); await page.getByLabel('Email').fill('qa@example.com'); await page.getByLabel('Password').fill(process.env.QA_PASSWORD); await solveTurnstile(page); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); });
fetch is global on Node 18 and later, so the test file needs no HTTP dependency. The official Node SDK (npm i solvegate) wraps the same call as sg.solve({ gate, sitekey, url }) if you prefer typed errors.
The Python API differences
Playwright's Python bindings are the same object model with different spelling. Methods are snake_case, page.url and locator.first are properties rather than calls, expect comes from playwright.sync_api, and page.evaluate accepts exactly one argument — pack multiple values into a list or dict.
# tests/test_login.py import os import requests from playwright.sync_api import Page, expect INJECT = """([token, field, callback]) => { document.querySelectorAll(`input[name="${field}"]`).forEach((input) => { input.value = token; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); }); const cb = callback && window[callback]; if (typeof cb === 'function') cb(token); }""" def solve_turnstile(page: Page) -> str: widget = page.locator("[data-sitekey]").first # property, not a call expect(widget).to_be_attached(timeout=15_000) sitekey = widget.get_attribute("data-sitekey") callback = widget.get_attribute("data-callback") field = widget.get_attribute("data-response-field-name") or "cf-turnstile-response" page.locator(f'input[name="{field}"]').wait_for(state="attached") res = requests.post( "https://api.solvegate.io/v1/solve", headers={"Authorization": f"Bearer {os.environ['SOLVEGATE_KEY']}"}, json={"gate": "turnstile", "sitekey": sitekey, "url": page.url}, # property timeout=60, ) res.raise_for_status() token = res.json()["token"] page.evaluate(INJECT, [token, field, callback]) return token def test_sign_in(page: Page): page.goto("https://staging.example.com/login") page.get_by_label("Email").fill("qa@example.com") page.get_by_label("Password").fill(os.environ["QA_PASSWORD"]) solve_turnstile(page) page.get_by_role("button", name="Sign in").click() expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()
The solvegate package on PyPI (Python 3.9+) replaces the requests block with sg.solve(gate="turnstile", sitekey=..., url=...) if you want retries and typed errors instead of raw HTTP.
Waiting strategies, and why hardcoded waits fail
page.waitForTimeout(5000) after the page loads is the single most common cause of a flaky Turnstile test. The wait you tuned against an invisible widget on a residential IP is too short the moment CI triggers a managed challenge, and too long once the widget resolves fast — which matters, because the 300-second token clock starts at issue, not at submit. A fixed sleep is a guess about a value that changes every run.
Wait for the state you actually care about: a non-empty response field. Web-first assertions retry until the condition holds or the timeout expires, so one assertion replaces the sleep and the retry loop.
import { expect } from '@playwright/test'; /** Resolve when the widget produces its own token. No sleeps, no polling loop. */ export async function waitForTurnstileToken(page, { timeout = 30_000 } = {}) { const field = page.locator('input[name="cf-turnstile-response"]'); await field.waitFor({ state: 'attached', timeout }); await expect(field).toHaveValue(/.+/, { timeout }); // auto-retries until non-empty return field.inputValue(); } // With a test sitekey this resolves in well under a second, so it costs nothing // to keep in the happy path and it fails loudly when the widget never resolves. // Only reach for a solver when this assertion times out on a real key.
toHaveValue works on the hidden input — visibility is not required, only attachment. Keep the timeout generous and the assertion specific. A test that fails in 30 seconds with "expected value matching /.+/" tells you the widget never issued a token; a test that fails at click tells you nothing.
What still goes wrong
- **The app calls
turnstile.getResponse()instead of reading the form field.** Settinginput.valueis invisible to that code path. You need the callback to fire, or a real widget flow. - **
data-response-fieldisfalse.** No hidden input is created at all; the app collects the token purely through its callback. Inject by calling the callback. - **Explicit rendering.** With
turnstile.render()there may be nodata-sitekeyin the DOM. Read the sitekey from the same env var your app builds with rather than scraping it. - **More than one widget.** A page with a login form and a signup form has two response fields. Scope the locator to the form you are submitting.
- **Token reuse.** Each token validates once; a replay returns
timeout-or-duplicate. Solve per submission, never per suite. - **Retry churn.** With
retry: autothe widget can re-render and blank the field after you wrote to it. Inject immediately before the click, not at the top of the test.
SolveGate covers Cloudflare Turnstile — managed, non-interactive and invisible — plus Turnstile WAF challenge pages. It does not solve reCAPTCHA, hCaptcha, GeeTest, FunCaptcha or AWS WAF, so if your form carries one of those, none of the code above applies.
Common questions
No. The challenge runs inside a cross-origin iframe served from challenges.cloudflare.com, and in managed or invisible mode there is nothing to click. Playwright can wait for a token, read it and inject one, but it cannot produce one. Stealth plugins change the odds, not the mechanism, and they make CI results non-deterministic.
Use 1x00000000000000000000AA for a visible always-pass widget, or 1x00000000000000000000BB for the invisible variant. Pair it with the secret key 1x0000000000000000000000000000000AA on the server. Use 2x00000000000000000000AB with 2x0000000000000000000000000000000AA to test your failure path, and 3x00000000000000000000FF to force an interactive challenge.
Four usual causes: the token was already redeemed (each one validates exactly once), it is older than 300 seconds, the server is running a test secret key that only accepts the dummy token, or the application reads turnstile.getResponse() rather than the form field, so it never sees the value you wrote.
Not for the test-sitekey path — it resolves identically headless in CI. For a real widget, the solve happens server-side at the API, so your browser mode does not affect whether a token is issued. Headed mode only matters if you are debugging what the widget renders.
SolveGate typically returns a token in under 1.5 seconds via POST /v1/solve. If you fire the request asynchronously while the test fills the rest of the form, most of that overlaps with work you were doing anyway.
The test-sitekey path costs nothing. For real widgets, SolveGate is prepaid credits from $0.40 per 1,000 solves down to $0.075 at volume, failed solves are never billed, and the first 1,000 solves are free — enough to cover a small suite's first few weeks.
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