Cloudflare Turnstile in Playwright: Why Your Tests Stall and How to Solve It in 8 Lines
If you're running Playwright or Selenium against any site behind Cloudflare, you've already met Turnstile. It's the new "managed challenge" widget Cloudflare started shipping in 2023, and it now appears in front of login flows, contact forms, signup pages, and increasingly the entire site root.
Here's the part most teams miss: Turnstile doesn't always show a checkbox. A lot of the time it just sits invisible, runs its scoring loop, and either issues a token silently or stalls forever. Your test doesn't crash. It just times out at the next page.click("button[type=submit]"). The CI log says "element not interactable." Nobody knows why.
I work on CaptchaAI. I'm going to show you exactly what's happening, then drop in 8 lines that fix it.
The real scenario
You have a Playwright suite that runs every PR. One day a test starts failing on the signup flow. You re-run it. It fails again. Locally on your laptop it passes. On CI it doesn't.
What's actually happening: Cloudflare flagged your CI runner's IP block (GitHub Actions, GitLab runners, Hetzner, OVH, DO — all of them are on Cloudflare's "elevated risk" list). Turnstile decides to switch from invisible mode to "managed challenge" mode. Now there's a widget in the DOM that needs a real token before the form submit will accept.
Your test never interacted with the widget because last week it didn't exist.
Why retries don't help
The instinct is to add a retry: 2 and move on. Don't. Cloudflare's scoring is per-IP-per-fingerprint, and each retry from the same runner makes the next challenge harder, not easier. After ~3 attempts you'll get full block pages instead of the widget.
The right move is to solve the widget once, inject the token, and submit normally — exactly what a human user does, just faster.
How Turnstile actually issues a token
The widget renders an iframe pointing at challenges.cloudflare.com. Inside the iframe it runs a fingerprint/behavior scoring loop for 1–4 seconds. When it's satisfied, it calls back into the parent page via a hidden <input name="cf-turnstile-response"> and fills the value attribute with a JWT-shaped token. That token is what the backend validates against Cloudflare's siteverify endpoint.
To solve Turnstile programmatically you need three things from the page:
- The sitekey — visible in the page source as
data-sitekey="0x4AAA..." - The page URL Turnstile was loaded on (Cloudflare validates this)
- A solver service that returns a valid token for that pair
Then you inject the token into the hidden input and trigger the form submit. That's it.
Code
Here's a Playwright + Python integration. Endpoint and key:
- Submit:
https://ocr.captchaai.com/in.php - Poll:
https://ocr.captchaai.com/res.php - API key placeholder:
YOUR_API_KEY(get one at https://captchaai.com/trial — 3 days, 5 threads, no card).
import time, requests
from playwright.sync_api import sync_playwright
API_KEY = "YOUR_API_KEY"
TARGET_URL = "https://example.com/signup"
def solve_turnstile(sitekey: str, page_url: str) -> str:
# 1. submit the task
r = requests.post(
"https://ocr.captchaai.com/in.php",
data={
"key": API_KEY,
"method": "turnstile",
"sitekey": sitekey,
"pageurl": page_url,
"json": 1,
},
timeout=20,
).json()
if r.get("status") != 1:
raise RuntimeError(f"submit failed: {r}")
task_id = r["request"]
# 2. poll for the token (typical solve: 8-20s)
for _ in range(30):
time.sleep(3)
rr = requests.get(
"https://ocr.captchaai.com/res.php",
params={"key": API_KEY, "action": "get", "id": task_id, "json": 1},
timeout=20,
).json()
if rr.get("status") == 1:
return rr["request"] # the cf-turnstile-response token
if rr.get("request") != "CAPCHA_NOT_READY":
raise RuntimeError(f"solve failed: {rr}")
raise TimeoutError("turnstile solve timed out")
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto(TARGET_URL)
sitekey = page.locator("[data-sitekey]").first.get_attribute("data-sitekey")
token = solve_turnstile(sitekey, TARGET_URL)
# inject the token where Cloudflare expects it
page.evaluate(
"(t) => { document.querySelector('[name=cf-turnstile-response]').value = t }",
token,
)
page.click("button[type=submit]")
page.wait_for_url("**/welcome", timeout=15000)
browser.close()
Same flow in Node.js with Playwright:
const { chromium } = require('playwright');
const API_KEY = 'YOUR_API_KEY';
const TARGET_URL = 'https://example.com/signup';
async function solveTurnstile(sitekey, pageUrl) {
const submit = await fetch('https://ocr.captchaai.com/in.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
key: API_KEY, method: 'turnstile',
sitekey, pageurl: pageUrl, json: '1',
}),
}).then(r => r.json());
if (submit.status !== 1) throw new Error('submit failed: ' + JSON.stringify(submit));
const id = submit.request;
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 3000));
const poll = await fetch(
`https://ocr.captchaai.com/res.php?key=${API_KEY}&action=get&id=${id}&json=1`,
).then(r => r.json());
if (poll.status === 1) return poll.request;
if (poll.request !== 'CAPCHA_NOT_READY') throw new Error(JSON.stringify(poll));
}
throw new Error('turnstile solve timed out');
}
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(TARGET_URL);
const sitekey = await page.locator('[data-sitekey]').first().getAttribute('data-sitekey');
const token = await solveTurnstile(sitekey, TARGET_URL);
await page.evaluate(
(t) => { document.querySelector('[name=cf-turnstile-response]').value = t; },
token,
);
await page.click('button[type=submit]');
await page.waitForURL('**/welcome', { timeout: 15000 });
await browser.close();
})();
Expected output: the form submits, you land on /welcome, your test passes. Typical end-to-end overhead is 8–20 seconds for the solve.
Troubleshooting
A few real gotchas I've watched teams hit:
-
Wrong sitekey. If the page lazy-loads Turnstile (some React/Next sites do this),
[data-sitekey]won't be in the DOM atpage.goto. Wait for it:page.wait_for_selector('[data-sitekey]', timeout=10000). -
Multiple Turnstile widgets. Login + reset-password + signup pages can all render the widget. Use a more specific selector (parent form +
[data-sitekey]) so you don't solve the wrong one. - Token expired. Turnstile tokens are valid for ~300 seconds. If your test does a long DB seed step between solving and submitting, re-solve right before the click.
-
Site uses Turnstile in
invisiblemode. Look fordata-size="invisible". The flow is identical — you still inject the token into the hidden input. The only difference is there's no widget UI rendered at all. -
Backend uses a non-default secret. That's a Cloudflare configuration on the site owner's side; the token is still valid, your code is fine. If the backend rejects, the failure is at their
siteverifystep, not yours.
How this fits into a real CI pipeline
What I do in my own setup:
- Run Playwright suites against a staging environment that's also behind Turnstile (don't whitelist your CI IPs at Cloudflare — that just hides the bug from yourself).
- Set the solver as a thin module: one
solve_turnstilefunction imported across every test that needs it. - Keep concurrency aligned with your thread allocation. If your plan gives you 5 threads and you run 12 parallel Playwright workers, 7 of them will queue. The BASIC plan ($15/mo) gives 5 threads with unlimited solves — fine for one repo. For monorepos with many parallel suites, STANDARD (15 threads, $30/mo, or $27 on /lp/new-year-deals) is the realistic floor.
- Add a single retry on solver timeout — but not on Cloudflare block. The two failure modes need different handling.
Pricing for context (current as of 2026-06-02): unlimited solves on every plan, you pay only for thread concurrency. Trial is at https://captchaai.com/trial (3 days, 5 threads, no card). If you want a discount, /lp/new-year-deals has up to 17% off on STANDARD through ENTERPRISE.
What to do next
If you have a flaky test that times out at form submit on a Cloudflare-fronted page: drop the 8-line solver above into a helper, swap the sitekey lookup, and run it from a clean CI runner.
— Bassem
(disclosure: CaptchaAI maintainer)
Top comments (0)