You wrote a clean scraper, pointed it at the target, and Cloudflare Turnstile stopped it dead. Your requests call comes back with a challenge page instead of data, and retrying changes nothing. Here's why that happens and the exact code I use to get past it.
The short version: a plain HTTP client can't pass Turnstile because it can't run the JavaScript the widget uses to score the browser. So you stop fighting the widget. You get a valid cf-turnstile-response token from something that can run that JS, and you submit the token with your request. That's the whole move, and it's a few lines.
Why requests alone can't do it
Turnstile isn't a puzzle you answer. It's a script that runs in a real browser and scores how browser-like the environment looks — canvas, timing, the shape of the navigator object, whether events look human. A bare requests or httpx call has no JavaScript engine, so it can't run that script at all, which means it can never produce the token the server wants. The server sees a missing or invalid cf-turnstile-response and rejects you.
This is also why time.sleep() and retry loops do nothing. Nothing about a retry changes what Cloudflare is scoring. You can loop for an hour; the client still can't run the JS.
What you actually need
Two moving parts.
The sitekey. It's the public Turnstile identifier sitting on the page. Look for data-sitekey on the Turnstile div, or a turnstile.render() call in the JS:
import re
import requests
target = "https://target.com/login"
html = requests.get(target, timeout=20).text
m = re.search(r'data-sitekey=["\']([^"\']+)["\']', html)
sitekey = m.group(1) if m else None
print(sitekey) # e.g. 0x4AAAAAAA...
If it isn't in the initial HTML, it's injected by JS — open DevTools, filter the network tab for turnstile, and the sitekey shows up in the challenges.cloudflare.com request.
A token. You send the page URL, the sitekey, and a proxy to a solving service that runs a real browser on a residential IP, and you get a token back. Then you attach it to your own plain-HTTP request.
import requests
API_KEY = "pk_your_api_key"
TARGET = "https://target.com/login"
SITEKEY = "0x4AAAAAAA..."
PROXY = "http://user:pass@ip:port"
# 1) get a Turnstile token (comes back in about a second)
resp = requests.post(
"https://api.peak.fo/solve",
headers={"X-API-Key": API_KEY},
json={
"task_type": "turnstiletask",
"url": TARGET,
"sitekey": SITEKEY,
"proxy": PROXY,
},
timeout=30,
).json()
if not resp.get("success"):
raise RuntimeError(f"solve failed: {resp.get('error')}")
token = resp["data"]["token"]
# 2) submit the token with your request, before it expires (~300s)
r = requests.post(TARGET, data={
"email": "you@example.com",
"password": "...",
"cf-turnstile-response": token,
})
print(r.status_code)
The value you get back is a normal cf-turnstile-response. The server can't tell it apart from one a browser produced, because it isn't different.
Disclosure: I work on Peak, the API in that snippet, so weigh the mention accordingly. The flow is close to identical across providers — POST url + sitekey, get a token, attach it — so the code changes by one URL and a couple of JSON keys if you use a different one. On price, Peak is $0.90 per 1,000 successful solves, dropping toward $0.35 at volume, and a miss costs nothing since you're only billed for tokens that come back valid. There's about 1,000 free solves to test with before you add funds.
The four ways a valid token still fails
When you have a token and it still bounces, it's almost always one of these:
- Expired. Turnstile tokens last around 300 seconds and are single-use. Solve right before the request that needs it, not at the top of a long script.
- Reused. Each submission needs a fresh token. The server redeems it once and the second request with the same value fails.
- Wrong sitekey. A token solved against one sitekey won't validate on a different page. Re-pull it from the live page.
-
IP mismatch. Some deployments tie the token to the solving context. Solve through the same proxy you submit from, and pass the widget's
actionif it sets one.
When you need a browser instead
If your flow has to stay on the page and keep interacting after the challenge clears — a session cookie tied to the device, cf_clearance you also need, behavioral checks across the whole flow — then a bare token won't carry you, and driving a real browser with the token injected makes more sense. For a straight scrape where you just need to get past the gate and read data, the code above is all it takes.
Originally published on the Peak blog.
Top comments (0)