Your scraper works fine against the site's HTML, then one day every request comes back 403 — or the Turnstile widget just spins forever and never returns a token. Same code, same proxies, nothing changed on your end. Here's what's actually going on and the approach I've landed on after fighting it across a few dozen targets.
Two things get you blocked, not one
Cloudflare Turnstile scores the browser, and separately Cloudflare scores the connection. A 403 usually means you failed one of these:
-
Fingerprint. Turnstile runs JavaScript that pokes at the browser — canvas, WebGL, timing, the shape of your navigator object, whether events look human. A plain
requestsorhttpxclient runs none of that JS, so there's nothing to score and the challenge never clears. Headless Chrome runs the JS but leaks automation signals unless you go out of your way to hide them. - IP reputation. This is the one people miss. Cloudflare keeps lists of datacenter ranges. If you're coming from AWS, GCP, Hetzner, OVH, or a cheap datacenter proxy, you can have a perfect fingerprint and still get thrown into an endless challenge, because the IP is the tell.
A stealth browser can fix the first problem. It cannot fix the second. That's why people burn a weekend on undetected-chromedriver tweaks and still get walls of 403s — they solved fingerprinting and left the IP problem untouched.
The token is separate from your request
The thing that unlocks a clean fix: the widget produces a token — cf-turnstile-response — and on most deployments that token is validated server-side against the sitekey and the hostname, not against the IP that produced it. The site's backend calls Cloudflare's siteverify with the token and gets back pass/fail.
So you don't have to make your scraper's browser pass the challenge. You need a valid token for that sitekey and hostname, produced by something that can run the JS from a residential-looking IP, and then you attach it to your own request.
Getting the sitekey
It's sitting in the page. Look for data-sitekey on the Turnstile div, or a render() call in the JS:
import re
import requests
target = "https://example.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's not in the initial HTML it's injected by JS — open DevTools, filter network for turnstile, and you'll see the sitekey in the challenges.cloudflare.com request.
Producing a token
Two honest options.
Run a real browser yourself. Playwright or Selenium with a genuine profile, pointed through a residential proxy, loading the page and reading the token out of the DOM after the widget resolves:
# token = page.locator("[name=cf-turnstile-response]").input_value()
This works when your fingerprint is clean and your IP is residential. It's the cheapest per-token if you already own good proxies and don't mind babysitting browser instances. It's slow (a full browser per solve) and it breaks whenever Cloudflare ships a new challenge variant, which is often.
Call a solving API. You hand it the URL and sitekey; it runs the browser farm on residential IPs and hands back a token. You keep your own scraper as a plain HTTP client. This is what I reach for when I care more about throughput than about owning the whole stack.
import requests
API_KEY = "pk_your_api_key"
TARGET = "https://example.com/login"
SITEKEY = "0x4AAAAAAA..."
# 1) get a token — comes back in ~1s
resp = requests.post(
"https://api.peak.fo/solve",
headers={"X-API-Key": API_KEY},
json={
"task_type": "TurnstileTaskProxyLess",
"url": TARGET,
"sitekey": SITEKEY,
},
timeout=60,
).json()
if not resp.get("success"):
raise RuntimeError(f"solve failed: {resp.get('error')}")
token = resp["data"]["token"]
# 2) attach the token to your real request, before it expires
r = requests.post(TARGET, data={
"email": "you@example.com",
"password": "...",
"cf-turnstile-response": token,
})
print(r.status_code)
If the target binds the token to the solving IP (some do), pass a proxy field so the token is minted through the same residential IP you'll submit from.
Disclosure: I work on Peak, the API in that second snippet, so take the mention with that in mind. The API shape is close to interchangeable across providers — the request keys differ but the flow (POST url+sitekey, poll or await, get token) is the same. On price, most providers cluster around $1.20 to $1.45 per 1,000 Turnstile solves. Peak is $0.90 per 1,000 successful solves, dropping toward $0.35 at volume, and you're only billed for tokens that actually come back valid, with about 1,000 free solves to test first. Try whichever; the code above changes by one URL and a couple of JSON keys.
The four ways a valid token still fails
Once you have a token and it still doesn't work, it's almost always one of these:
- Expired. Turnstile tokens live about 300 seconds. If you solve, then sit in a queue for five minutes, you're submitting a dead token. Solve right before you submit.
-
Reused. A token is single-use. The second request with the same
cf-turnstile-responsefails. One solve, one submit. - Wrong sitekey. You grabbed a sitekey from a different widget on the page, or from a cached older version. Re-pull it from the live page.
- IP mismatch. The site's backend compares the solving IP to the submitting IP. If yours differ, solve through the same proxy you submit from.
When none of this helps
If the site validates far more than the token — device fingerprint tied to a session, behavioral signals across the whole flow, a cf_clearance cookie you also need — then a bare token won't carry you, and you're back to driving a full, well-fingerprinted browser through the entire session. Turnstile is one gate; some sites stack several. Fix the token gate first, because it's the one that's cleanly separable, then see what's left.
Originally published on the Peak blog.
Top comments (0)