惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog
A
About on SuperTechFans
Microsoft Security Blog
Microsoft Security Blog
Y
Y Combinator Blog
罗磊的独立博客
J
Java Code Geeks
人人都是产品经理
人人都是产品经理
MongoDB | Blog
MongoDB | Blog
The GitHub Blog
The GitHub Blog
G
Google Developers Blog
U
Unit 42
Microsoft Azure Blog
Microsoft Azure Blog
博客园 - Franky
Jina AI
Jina AI
F
Fortinet All Blogs
H
Help Net Security
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Last Week in AI
Last Week in AI
博客园 - 司徒正美
云风的 BLOG
云风的 BLOG
M
MIT News - Artificial intelligence
C
Check Point Blog
GbyAI
GbyAI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Cloudflare Turnstile in Playwright: Why Your Tests Stall ...
B.Shahin · 2026-06-04 · via DEV Community

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:

  1. The sitekey — visible in the page source as data-sitekey="0x4AAA..."
  2. The page URL Turnstile was loaded on (Cloudflare validates this)
  3. 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()

Enter fullscreen mode Exit fullscreen mode

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();
})();

Enter fullscreen mode Exit fullscreen mode

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 at page.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 invisible mode. Look for data-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 siteverify step, not yours.

How this fits into a real CI pipeline

What I do in my own setup:

  1. 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).
  2. Set the solver as a thin module: one solve_turnstile function imported across every test that needs it.
  3. 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.
  4. 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)