guides / python

Python Cloudflare Turnstile Solver

To solve Cloudflare Turnstile from Python you need one thing: a valid token for the page's sitekey, posted back in the cf-turnstile-response field. No browser is required — a requests.Session and one API call is the whole path, and it runs in about a second instead of the five to twenty seconds a driven Chromium takes.

How Turnstile works over the wire

Turnstile has two halves. In the browser, a script from challenges.cloudflare.com runs browser-integrity and proof-of-work checks, then writes a token into a hidden input. When the widget sits inside a <form>, Cloudflare creates that input automatically and names it cf-turnstile-response. On the server, the site posts the token to https://challenges.cloudflare.com/turnstile/v0/siteverify along with its secret key, and Cloudflare answers with a success flag.

Your Python code only participates in the first half. It needs a token bound to the right sitekey and page URL, and it needs to put that token in the form body. Everything around it — cookies, redirects, CSRF fields — is ordinary HTTP that requests already handles.

Two rules from Cloudflare's own documentation shape every design here. A token is valid for 300 seconds after it is issued, and siteverify accepts each token exactly once — a replay returns timeout-or-duplicate. So solve as late as you can, use the token immediately, and never cache one between runs.

A Turnstile widget on a form is not the same thing as a Turnstile WAF challenge page, the full-page interstitial that gates a whole route before any HTML reaches you. Both are covered below; they take different gate values.

Point this at properties you own or are authorised to test — your own staging login, your CI pipeline, your synthetic monitors. That is the boundary, and it is the only use this guide describes.

Three ways to get a token from Python

The approaches differ mainly in what has to be running on the machine when a token is produced.

ApproachWhat runs per workerTypical time to tokenFits when
Selenium plus an anti-detect driverA full Chromium, 300–700 MB RAMSeconds, and brittle across Chrome releasesYou already need the page's JavaScript for the rest of the test
Patched Playwright (patchright, Camoufox)A full Chromium, plus fingerprint patchingA few seconds when it worksYour test genuinely drives a UI and asserts on rendered state
requests plus a solver APIOne HTTPS callAround 1.5s with SolveGateYou only need the token so a form post or an API call succeeds

If your end-to-end test clicks through the UI anyway, keep the browser and hand it a token. If you are posting a login form, hitting an internal API behind a WAF challenge, or running a synthetic check every minute, the browser is pure overhead — you are paying half a gigabyte of RAM and ten seconds to produce a 400-byte string.

The plain-requests path, no browser

This is the complete flow: fetch the page, read the sitekey out of the HTML, get a token, post the form on the same session so cookies carry. POST /v1/solve takes gate, sitekey and url, authenticated with a bearer secret key, and returns the token in the response body.

python
"""Solve a Turnstile widget and post the form. No browser.

    pip install requests
    export SOLVEGATE_KEY=sk_test_...
"""
import os
import re
import sys
from typing import Optional

import requests

SOLVE_URL = "https://api.solvegate.io/v1/solve"
API_KEY = os.environ["SOLVEGATE_KEY"]
LOGIN_URL = "https://staging.example.com/login"

SITEKEY_RE = re.compile(r'data-sitekey=["\']([^"\']+)["\']')


def find_sitekey(html: str) -> str:
    match = SITEKEY_RE.search(html)
    if not match:
        raise RuntimeError("No data-sitekey in the HTML; the widget is rendered by JS.")
    return match.group(1)


def solve_turnstile(sitekey: str, url: str, action: Optional[str] = None) -> str:
    payload = {"gate": "turnstile", "sitekey": sitekey, "url": url}
    if action:
        payload["action"] = action

    resp = requests.post(
        SOLVE_URL,
        json=payload,
        headers={"Authorization": "Bearer " + API_KEY},
        timeout=90,
    )
    body = resp.json()

    if resp.status_code != 200:
        err = body.get("error", {})
        raise RuntimeError("%s %s: %s" % (resp.status_code, err.get("code"), err.get("message")))
    if body.get("mode") == "sandbox":
        print("sandbox key: this token will not clear a real gate", file=sys.stderr)
    return body["token"]


def main() -> None:
    with requests.Session() as s:
        s.headers["User-Agent"] = (
            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
        )
        page = s.get(LOGIN_URL, timeout=30)
        page.raise_for_status()

        token = solve_turnstile(find_sitekey(page.text), LOGIN_URL)

        posted = s.post(
            LOGIN_URL,
            data={
                "email": "qa@example.com",
                "password": os.environ["QA_PASSWORD"],
                "cf-turnstile-response": token,
            },
            timeout=30,
            allow_redirects=True,
        )
        print(posted.status_code, posted.url)


if __name__ == "__main__":
    main()

Three details that decide whether this works. Set the client timeout well above the solve time — 90 seconds leaves room, since the request blocks until the gate clears. Pass action if the widget carries a data-action attribute, because the site's siteverify check may compare it. And keep the token on the same requests.Session that fetched the page, so any session cookie the form depends on is still attached.

If the regex finds nothing, the widget is being rendered by JavaScript through turnstile.render() rather than the implicit class="cf-turnstile" markup. Read the sitekey out of the page's JS bundle once and hardcode it — it is a public value that rarely changes.

The SolveGate Python SDK

solvegate on PyPI is the same API with the retries, the error envelope and the polling loop already written. It has zero runtime dependencies — standard library only — and supports Python 3.9 and up.

bash
pip install solvegate
export SOLVEGATE_KEY=sk_test_your_key_here

The synchronous call blocks until the gate clears. Solve carries .id, .status, .gate, .token, .solve_ms, .expires_at, .billed and .meter.

python
import os

from solvegate import SolveGate

sg = SolveGate(os.environ["SOLVEGATE_KEY"])

# Blocking: returns once the gate clears.
res = sg.solve(
    gate="turnstile",
    sitekey="0x4AAAAAAAAA_target",
    url="https://staging.example.com/login",
)
print(res.token, res.solve_ms, res.meter)

# Non-blocking: 202 straight back, then poll. GET /v1/solve/{id} is free.
pending = sg.solve(
    gate="turnstile",
    sitekey="0x4AAAAAAAAA_target",
    url="https://staging.example.com/login",
    async_=True,
)
print(pending.id, pending.status)

solved = sg.wait(pending.id, interval=0.5, timeout=90)
if solved.status == "solved":
    print(solved.token)
else:
    print("failed:", solved.status)

Note the trailing underscore on async_async is a reserved word in Python, so the SDK renames it on the way to the API's async field. For a WAF challenge page pass gate="waf" instead; the rest of the call is identical.

Test keys (sk_test_) return a deterministic response with mode set to sandbox and a token prefixed SANDBOX., so you can wire up and assert on the whole path in CI without touching a real gate or spending anything. Branch on mode before you trust a token. Live keys cost from $0.40 per 1,000 solves down to $0.075 at volume, the first 1,000 solves are free, and a failed solve is never billed.

Async and concurrency

The SDK is synchronous by design, which makes it trivial to use from asyncio: wrap the call in asyncio.to_thread and bound the fan-out with a semaphore so you stay under your key's rate limit. The blocking call spends nearly all its time waiting on a socket, so a thread per in-flight solve costs almost nothing.

python
import asyncio
import os
from typing import Optional

from solvegate import SolveGate, SolveGateError

sg = SolveGate(os.environ["SOLVEGATE_KEY"])
limiter = asyncio.Semaphore(8)


async def token_for(sitekey: str, url: str) -> Optional[str]:
    async with limiter:
        try:
            res = await asyncio.to_thread(
                sg.solve, gate="turnstile", sitekey=sitekey, url=url
            )
        except SolveGateError as exc:
            print("solve failed:", exc.code, exc)
            return None
    return res.token


async def main() -> None:
    targets = [
        ("0x4AAAAAAAAA_target", "https://staging.example.com/login"),
        ("0x4AAAAAAAAA_target", "https://staging.example.com/signup"),
    ]
    tokens = await asyncio.gather(*(token_for(k, u) for k, u in targets))
    print([t[:12] + "..." if t else None for t in tokens])


asyncio.run(main())

asyncio.to_thread arrived in Python 3.9, the same floor as the SDK. If you would rather not spend threads at all, use httpx.AsyncClient against POST /v1/solve with async: true and poll GET /v1/solve/{id} yourself — that path holds no connection open between the request and the answer, which matters at hundreds of concurrent solves.

Remember the 300-second token lifetime when you fan out. Solving 500 tokens up front and replaying them through a slow queue means the tail expires. Solve inside the worker that is about to use the token.

Errors and retries

The API returns a stable envelope — { "error": { "code", "message", "billed" } } — and the SDK raises SolveGateError carrying .code, .status and .billed. The only decision that matters is whether a code is worth retrying.

CodeStatusRetry?
invalid_key401No. Fix the key.
balance_empty402No. Top up first.
unknown_sitekey422No. The sitekey and URL do not name a live gate; retrying repeats the same answer.
rate_limited429Yes, after Retry-After. The SDK does this for you.
solve_timeout504Yes. Not billed.
user_proxy_error502Yes, after checking your own proxy's credentials and egress region.

The SDK already retries 429 using the server's Retry-After header and falls back to capped exponential backoff on network errors. What it deliberately does not do is retry a failed solve, because that is a billing and policy decision. Wrap it yourself, and keep a hard list of codes that will never come good.

python
import os
import random
import time

from solvegate import SolveGate, SolveGateError

sg = SolveGate(os.environ["SOLVEGATE_KEY"], max_retries=3)

FATAL = {
    "invalid_key",
    "unknown_sitekey",
    "balance_empty",
    "spend_cap_reached",
    "key_budget_exceeded",
    "forbidden_target",
    "bad_request",
}


def solve_with_retry(sitekey: str, url: str, attempts: int = 3):
    last = None
    for i in range(attempts):
        try:
            return sg.solve(gate="turnstile", sitekey=sitekey, url=url)
        except SolveGateError as exc:
            last = exc
            if exc.code in FATAL:
                raise
            if i == attempts - 1:
                break
            time.sleep(min(8.0, 0.5 * 2 ** i) + random.uniform(0, 0.25))
    raise last


res = solve_with_retry("0x4AAAAAAAAA_target", "https://staging.example.com/login")
print(res.token, res.solve_ms, res.meter, res.billed)

The jitter matters more than the backoff curve. A CI job that starts twenty workers at once will otherwise retry them all in lockstep and turn one transient failure into a rate-limit storm.

For idempotency across a flaky network, pass idempotency_key= to sg.solve(). A replayed request with the same key returns the original solve rather than starting — and billing — a second one.

Common questions

Yes. Turnstile's server-side check only validates a token string submitted as cf-turnstile-response. Nothing in that check requires the token to have come from your process, so requests plus a solving API is sufficient for form posts and API calls. You need a browser only when the rest of your test depends on rendered page state.

In the page HTML, on the widget container: <div class="cf-turnstile" data-sitekey="0x4AAAA...">. Sitekeys are public and start with 0x. If the widget is created by JavaScript via turnstile.render(), the key is in the page's JS bundle instead — read it once and hardcode it.

300 seconds from issue, per Cloudflare's documentation, and each token validates exactly once. A replay is rejected with timeout-or-duplicate. Solve immediately before you use the token, and never reuse or cache one.

The client is synchronous and dependency-free. From asyncio, call it through asyncio.to_thread with a semaphore bounding concurrency, as shown above. For very high fan-out, call POST /v1/solve with async: true from httpx.AsyncClient and poll GET /v1/solve/{id}, which is never billed.

No. It covers Cloudflare Turnstile — managed, non-interactive and invisible widgets — and Turnstile WAF challenge pages, selected with gate="turnstile" or gate="waf". reCAPTCHA, hCaptcha, GeeTest, FunCaptcha and AWS WAF are out of scope.

Nothing. Failed solves are never billed, and GET /v1/solve/{id} is free. Every response carries billed, so you can assert on it. The first 1,000 solves are free; after that credits run from $0.40 per 1,000 down to $0.075 at volume.

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