How to solve Cloudflare Turnstile in 2026 (a developer's guide)

python dev.to

If you scrape or automate anything, you've hit this wall. Requests that ran fine for months start coming back as 403s, or you get an HTML page that just says "Just a moment...". Open the URL in a real browser and there's a little Cloudflare box sitting on the form — Turnstile.

This post covers three things: what Turnstile actually checks, why your headless script gets blocked no matter how many stealth patches you stack on it, and the handful of approaches that actually get you a token when you need one. There's runnable requests and playwright code at the end.

Upfront disclosure: I work on Peak, and one of the options below is our own solving API — I'll list the pricing and free tier plainly. But most of this is method and mechanics that cost nothing, and you can read only that part if you want. This isn't a pitch dressed up as a tutorial.

Turnstile isn't a puzzle, it's a trust score

Old-school CAPTCHAs — reCAPTCHA v2 with the fire hydrants, hCaptcha's "pick the buses" — test whether you can solve a task. Turnstile mostly doesn't give you a task. It tests whether your browser looks like a real person's browser.

Here's what it does in the background:

  • Runs a chunk of obfuscated JS that collects a browser fingerprint — navigator properties, screen, timezone, WebGL and Canvas rendering, fonts, and whether anything on window smells like automation (navigator.webdriver, CDP-injected objects, and so on).
  • Runs consistency checks on behavior and environment: mouse movement, the isTrusted flag on events, execution timing, and whether the User-Agent you claim matches your real TLS and HTTP/2 fingerprint.
  • Weighs the reputation of your exit IP. Datacenter IPs — AWS, GCP, most VPS providers — start with a low score by default.

Score high enough and the widget fills in the cf-turnstile-response token itself, fires its callback, and your form submits like normal. Score too low and you either get an interactive challenge or you simply never get a token.

So the load-bearing idea: Turnstile isn't "solve one puzzle," it's "assemble one trustworthy browser session." That single fact explains why every method below works or doesn't.

Why your headless script gets a 403

Three reasons, biggest first:

  1. Datacenter IP. This is the number-one killer. You're firing requests from a cloud box, the IP reputation is near the floor, and Turnstile cranks the difficulty. The exact same code from a home broadband (residential) IP often just passes.
  2. Headless is loud. A headless=True Chromium has a pile of detectable tells. Even after undetected-chromedriver or playwright-stealth, the leftover CDP traces (Runtime.enable and friends) still light up for Cloudflare.
  3. A plain HTTP client has no JS engine at all. requests, httpx, curl_cffi can't run the challenge script Turnstile needs, so they never produce a token in the first place.

Before you reach for a solver, the cheapest fix that people skip: a lot of the time you don't need to solve anything. If the only reason you're being challenged is a datacenter IP, swap in a residential proxy and a browser with a real fingerprint (Camoufox, or a properly stealth-patched Playwright), and the widget frequently passes on its own — cf-turnstile-response fills itself in. Try that first. It's the lowest-cost path by a mile.

To tell which wall you've hit, look at the response:

  • The page embeds a <div class="cf-turnstile" data-sitekey="0x..."> form widget → that's the kind this guide handles.
  • The whole domain is behind a full-screen "Just a moment..." interstitial (the managed challenge / "5-second" page) → that's a different beast. It runs on the cf_clearance cookie, and I'll flag it separately at the end.

Step 1: read the sitekey

The sitekey is public. It's written into the HTML, starts with 0x, and every approach needs it first.

import re
import requests

resp = requests.get("https://target-site.com/login", headers={
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                  "Chrome/128.0.0.0 Safari/537.36"
})

# Most common: the data-sitekey attribute
m = re.search(r'data-sitekey=["\'](0x[A-Za-z0-9_-]+)["\']', resp.text)
sitekey = m.group(1) if m else None

# Some sites render it in JS: turnstile.render(el, {sitekey: "0x..."})
if not sitekey:
    m = re.search(r'sitekey["\']?\s*[:=]\s*["\'](0x[A-Za-z0-9_-]+)', resp.text)
    sitekey = m.group(1) if m else None

print("sitekey:", sitekey)
Enter fullscreen mode Exit fullscreen mode

If it's not in the HTML, the widget is being injected by JS. Open DevTools, filter the Network tab for challenges.cloudflare.com, and you'll see the sitekey in the request params — or just run document.querySelector('.cf-turnstile').dataset.sitekey in the console.

Step 2: get a valid token

Minting the token yourself on a server — as covered above — mostly fails on datacenter IPs with automation tells. Two realistic paths:

Path A — residential proxy + real browser, let the widget pass itself. Good when volume is low and you can afford to spin up a browser. Use Camoufox (a Firefox fork with solid fingerprint spoofing) behind a residential proxy, and a lot of checkbox-style Turnstiles clear silently. The downside is it's slow and memory-hungry, and it doesn't scale once volume climbs.

Path B — call a solving API, get back just the token. You send the url and sitekey to a service, it runs the challenge on its own browser farm with residential IPs, and hands you back a token. Your scraper stays pure HTTP the whole time — no browser. At scale this is basically the only sane option.

Services that do this: 2Captcha, CapSolver, YesCaptcha, and Peak, where I work. The interfaces are all similar; here's Peak as the example (swap the URL and field names for another provider).

import requests

API_KEY = "your_API_KEY"

def solve_turnstile(url, sitekey, proxy=None):
    payload = {
        "task_type": "turnstiletask",
        "url": url,          # keep the trailing slash
        "sitekey": sitekey,
    }
    if proxy:
        payload["proxy"] = proxy  # http://user:pass@ip:port
    r = requests.post(
        "https://api.peak.fo/solve",
        headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
        json=payload,
        timeout=120,
    )
    data = r.json()
    if not data.get("success"):
        raise RuntimeError(f"solve failed: {data}")
    return data["data"]["token"]

token = solve_turnstile("https://target-site.com/login/", sitekey)
print("token:", token[:40], "...")
Enter fullscreen mode Exit fullscreen mode

A real response looks like {"success": true, "data": {"token": "0.abc123..."}, "cost": 0.0009} — on a live hard target one came back in a few seconds. You only get charged on success.

One trap people hit constantly: if the token is going to be used behind a proxy afterward, the proxy you pass at solve time should match the exit IP your later request uses. Cloudflare sometimes binds the token to the IP that solved it. Mismatch the two and the server rejects the token as invalid even though it looks fine.

Step 3: use the token

Once you have a token, there are two cases.

Case one: pure HTTP, POST the token straight in

The Turnstile token ends up as a single form field named cf-turnstile-response. You POST it alongside your other form fields and you're done. A lot of login and submit endpoints are exactly this simple:

session = requests.Session()
session.headers.update({"User-Agent": "Mozilla/5.0 ... Chrome/128.0.0.0 Safari/537.36"})

r = session.post("https://target-site.com/login/", data={
    "username": "me",
    "password": "secret",
    "cf-turnstile-response": token,   # the key field
})
print(r.status_code, r.url)
Enter fullscreen mode Exit fullscreen mode

How do you find the field name? Open DevTools, submit the form once by hand, look at the form data on that POST in the Network tab, and copy it into your Python data=. It isn't always literally cf-turnstile-response — some sites rename it — so go by what you actually capture.

Case two: inject the token in the browser and keep going

If the target is a heavy single-page app and the submit logic lives in JS, put the token back into the page and manually fire Turnstile's callback so the frontend believes it passed:

from playwright.sync_api import sync_playwright

def inject_token(page, token):
    page.evaluate(
        """(token) => {
            // 1) fill the hidden response inputs
            document.querySelectorAll(
                'input[name="cf-turnstile-response"], input[name="g-recaptcha-response"]'
            ).forEach(el => {
                el.value = token;
                el.dispatchEvent(new Event('input',  { bubbles: true }));
                el.dispatchEvent(new Event('change', { bubbles: true }));
            });
            // 2) fire the widget's success callback (many sites rely on this, not the hidden input)
            const el = document.querySelector('.cf-turnstile');
            const cb = el && el.getAttribute('data-callback');
            if (cb && typeof window[cb] === 'function') {
                window[cb](token);
            }
        }""",
        token,
    )

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page()
    page.goto("https://target-site.com/login/")

    sitekey = page.get_attribute(".cf-turnstile", "data-sitekey")
    token = solve_turnstile(page.url, sitekey)   # reuse the function above

    inject_token(page, token)
    page.click("button[type=submit]")
    page.wait_for_load_state("networkidle")
    print(page.url)
Enter fullscreen mode Exit fullscreen mode

Two details decide whether this works:

  • data-callback. Plenty of sites have no visible submit button — the moment verification passes, they move on, driven entirely by this callback. Fill the hidden input without firing the callback and nothing happens. The code above does both.
  • Dispatching input / change. React and Vue don't notice a raw .value assignment. You have to dispatch the events by hand so the framework sees the change.

The traps worth memorizing

  • Tokens are single-use and short-lived. Usually valid for a minute or two, once. Don't cache, don't reuse — solve and spend immediately.
  • Keep the trailing slash on the URL. https://x.com and https://x.com/ can validate differently in some implementations. Pass the exact URL your request actually hits.
  • action / cData mismatch. If the site passes an action or cData into turnstile.render, you have to pass the matching value at solve time or the token won't validate. Both are findable in the HTML/JS.
  • The full-screen "Just a moment" page is not the form widget. That's the managed challenge (the "5-second shield"). It clears via the cf_clearance cookie, not a cf-turnstile-response token. Solving APIs usually have a separate endpoint for it (on Peak it's cloudflare5stask, and it returns cookies, not a token). Different call, different params — don't mix them up.

Choosing an approach

Running your own browser farm plus a residential proxy pool is fine at small volume, but the maintenance cost is higher than it looks once you scale — fingerprints have to keep pace with Cloudflare's changes, proxies rotate, failures need retries. So most people move to a ready-made API past a certain point. When you compare them, look at three things: billed per success or per request, residential proxy included or bring-your-own, and the real-world pass rate on Turnstile.

On price, roughly: 2Captcha's Turnstile runs about $1 per 1,000; CapSolver hovers around $0.80. For comparison, Peak's pricing is $0.90 per 1,000 successful Turnstile solves, dropping to $0.35 per 1,000 at volume, billed on success only — failures cost nothing; a new account gets roughly 1,000 free solves to run the flow end-to-end first. The endpoint is the POST https://api.peak.fo/solve from the code above.

If you're on Playwright, Selenium, Scrapy, curl_cffi or Puppeteer and don't want to hand-roll the injection, there are ready MIT-licensed wrappers (on GitHub under CircuitSavageplaywright-turnstile, selenium-turnstile, scrapy-turnstile, cloudscraper-turnstile, turnstile-curl) that bundle the whole read-sitekey → call-API → inject-token loop. There are drop-ins for JavaScript and Go too. Swap in an API key and go.

One thing worth saying plainly: this is for legitimate automation — scraping public data, testing your own sites, QA. Respect the target's terms of service and robots rules, and don't point any of it at logins that aren't yours. Solving a challenge doesn't grant permission you didn't already have.

Wrap-up

The right mental model for Turnstile isn't "solve a CAPTCHA," it's "assemble a trustworthy browser session." So the order is always:

  1. First swap in a residential proxy + real-fingerprint browser and let the widget pass on its own. This step is free and clears a large share of the datacenter-IP blocks.
  2. If it won't pass, or you need to scale, add a solving API for the token only and keep the scraper pure HTTP.
  3. With the token in hand, either POST it straight into the cf-turnstile-response field, or inject it in the browser and fire data-callback.
  4. Watch the traps: single-use tokens, IP binding, the trailing slash, action matching.

Understand what it's checking and you'll get further than by grinding on any single tool.

Source: dev.to

arrow_back Back to Tutorials