Cross-posted from blog.peak.fo. I work on Peak, a Cloudflare Turnstile solving API, so the code below calls it. The token-injection problem it solves is the same no matter which solver you use.
You built an agent. It reads a page, decides what to do, clicks the thing, moves on. Then it hits a target protected by Cloudflare Turnstile and just... sits there. The DOM snapshot shows a little widget, your LLM keeps trying to click it, and the run burns tokens until it times out. If you're running Browser Use, Stagehand, or a plain Playwright loop with a model in the driver's seat, you've probably already seen this.
This covers what's happening and how to get your agent past it, with code you can drop in today. It's for automation, QA, and scraping data you're allowed to access. Check each target's ToS and robots.txt before you point an agent at it.
Why AI browser agents stall on Turnstile
Turnstile isn't a puzzle your agent can reason its way through. There's nothing to read, no images to label, no slider to drag. It's a proof-of-work and signal-collection widget that runs in the browser, watches the client environment, and either issues a token silently or throws an interactive checkbox when it doesn't trust the session.
The token is the whole game. Turnstile writes a value into a hidden input named cf-turnstile-response and fires a JavaScript callback once it's satisfied. The form submit (or the XHR behind it) carries that token to Cloudflare for server-side validation. No valid token, no entry. Your agent can click the checkbox a hundred times and it won't matter, because the decision isn't about the click. It's about whether the client looks like a real browser on a real network.
That's exactly where agents fall down. Most agents run headless Chromium on a cloud box with a datacenter IP. Turnstile sees an automation-flavored environment and a hosting-provider egress, drops trust, and either stalls on a challenge that never resolves or hands back a token Cloudflare rejects. The agent did nothing wrong. Its network and browser fingerprint did.
Where it shows up in the agent loop
You'll see it in a few predictable spots:
- A login or signup step where Turnstile guards the form, so the agent fills every field correctly and the submit does nothing.
- A full-page interstitial (the Cloudflare "checking your browser" 5-second screen) that loads before your target content ever renders, so the agent's page-read returns a holding page.
- An action-triggered widget that only appears after a click, so the agent's plan looks fine until the exact moment it needs to proceed.
In every case the symptom is the same: the agent keeps planning against a page that won't advance. Your observability shows retries, longer and longer waits, and eventually a dead run.
The two real options
There are exactly two ways through, and it's worth being honest about both.
Option one: drive the widget in-browser. You harden the browser so Turnstile trusts it enough to issue a token on its own. That means a stealth-patched or anti-detect browser, a residential or mobile IP, realistic timing, and a lot of fingerprint work. The token comes out of your own session with zero external calls if it works. You're stuck tuning fingerprints against a detection system that updates on its own schedule if it doesn't. For an agent that needs to run reliably across many targets, this turns into a maintenance job that never ends.
Option two: fetch a token from a solving API and inject it. You send the page URL and the widget's sitekey to a service, it returns a valid cf-turnstile-response token in about a second, and you drop that token into the field and fire the callback. Your agent's browser stays simple. The hard part (producing a token Cloudflare accepts) happens somewhere built for it.
We build the second kind of thing, so take this with that in mind, but the reasoning holds up on its own: for an agent that has to keep working while you sleep, injection is less code and far less upkeep. You're not fighting a fingerprint arms race on every run.
Injecting a Peak-solved token into a Playwright or Browser Use agent
The flow has three moves. Read the sitekey off the page, ask the API for a token, write the token back into the DOM and trigger the callback. Then let the agent carry on.
Step 1: get the sitekey
The sitekey is public. It lives on the Turnstile element as a data-sitekey attribute and always starts with 0x. Pull it straight from the page:
sitekey = await page.evaluate("""
() => {
const el = document.querySelector('[data-sitekey]');
return el ? el.getAttribute('data-sitekey') : null;
}
""")
page_url = page.url
Step 2: ask Peak for a token
One POST to https://api.peak.fo/solve with your API key in the header. Use turnstiletaskproxyless for a standard Turnstile widget, which is the common case:
import requests
resp = requests.post(
"https://api.peak.fo/solve",
headers={"X-API-Key": "YOUR_API_KEY"},
json={
"task_type": "turnstiletaskproxyless",
"url": page_url,
"sitekey": sitekey,
},
timeout=30,
)
result = resp.json()
# {"success": true, "data": {"token": "1.7Nm..."}, "cost": 0.0009}
token = result["data"]["token"]
That usually comes back in about a second. You pay only for successful solves, so a failed attempt doesn't cost you.
Step 3: inject the token and fire the callback
Writing the token into the hidden input isn't always enough on its own. Plenty of integrations wait for Turnstile's callback before they enable the submit button or kick off the request. So set the field and call the callback:
await page.evaluate("""
(token) => {
// Fill every Turnstile response field on the page
document.querySelectorAll('[name="cf-turnstile-response"]')
.forEach(el => { el.value = token; });
// Fire the site's success callback if the widget registered one
if (window.turnstile && window.tsCallback) {
window.tsCallback(token);
}
}
""", token)
If you don't know the callback's name (you often won't), read it off the widget config. The data-callback attribute names the global function the site expects:
cb = await page.evaluate("""
() => {
const el = document.querySelector('[data-sitekey]');
return el ? el.getAttribute('data-callback') : null;
}
""")
if cb:
await page.evaluate(f"(t) => window['{cb}'] && window['{cb}'](t)", token)
Now your agent's next step (submit the form, or just re-read the page) goes through. In a Browser Use setup, the cleanest pattern is to run this whole sequence as a custom tool or controller action so the LLM can call "solve the Turnstile" the same way it calls "click" or "type," rather than trying to reason about the widget itself.
Same thing in JavaScript
If your agent stack is Node (Stagehand, Playwright JS, or your own loop), it's the same three moves:
const sitekey = await page.evaluate(() => {
const el = document.querySelector('[data-sitekey]');
return el ? el.getAttribute('data-sitekey') : null;
});
const res = await fetch("https://api.peak.fo/solve", {
method: "POST",
headers: {
"X-API-Key": process.env.PEAK_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
task_type: "turnstiletaskproxyless",
url: page.url(),
sitekey,
}),
});
const { data } = await res.json();
const token = data.token;
await page.evaluate((t) => {
document.querySelectorAll('[name="cf-turnstile-response"]')
.forEach((el) => { el.value = t; });
const el = document.querySelector('[data-sitekey]');
const cb = el && el.getAttribute('data-callback');
if (cb && window[cb]) window[cb](t);
}, token);
Handling the invisible and managed variants
Turnstile ships in three flavors and your agent needs to treat them differently.
Managed is the one that shows a checkbox when Cloudflare isn't sure about the session. The flow above handles it directly.
Invisible renders nothing visible and resolves in the background. The trap here is that there's no element for your agent to notice, so the LLM has no visual cue that a gate exists. Don't rely on the agent spotting it. Check for the hidden input or the sitekey element on any page where a submit mysteriously does nothing:
has_turnstile = await page.evaluate("""
() => !!document.querySelector('[data-sitekey]')
|| !!document.querySelector('[name="cf-turnstile-response"]')
""")
If it's there, run the solve before you submit, visible widget or not. The sitekey and the solve call are identical across managed and invisible modes.
The 5-second interstitial is a different animal. This is the full-page "checking your browser" screen that gates the whole site, not a widget inside a form. It's a harder challenge and it usually wants a token tied to a real-looking network path. Peak has a dedicated task type for it:
{
"task_type": "cloudflare5stask",
"url": "https://target.example/path",
"proxy": "http://user:pass@host:port"
}
Passing a proxy on the interstitial matters, which brings us to the last decision.
Rule of thumb: standard Turnstile widget, use proxyless. The 5-second interstitial or a region-locked target, pass your own proxy.
When to pass your own proxy
Peak's turnstiletaskproxyless works for most Turnstile widgets. The service handles the network side and you never touch a proxy. That's the default you want, because it's less to configure and less to pay for.
Switch to turnstiletask with your own proxy field when:
- The target is region-locked and the token has to come from an IP in the right country.
- You're clearing the 5-second interstitial, where a consistent network path makes the solve reliable.
- Your target correlates the solve session with the session that uses the token, so the IP that got the token needs to match the IP that submits it.
That last point is the one people miss. If you solve from Peak's network but your agent submits from a different IP, a strict target can notice the mismatch. For those cases, pass the same residential or mobile proxy to both the solve call and your browser, so the token and the request share an origin.
{
"task_type": "turnstiletask",
"url": "https://target.example/login",
"sitekey": "0x4AAAA...",
"proxy": "http://user:pass@host:port"
}
Honest trade-offs
Injection isn't magic, so here's the straight version.
You're adding a network round-trip to your agent loop. It's about a second per solve, which is nothing next to LLM latency, but it's not zero. Cache nothing: tokens are single-use and short-lived, so solve at the moment you need to submit, not ahead of time.
You're also trusting a third party with the page URL and sitekey, both of which are already public. No credentials leave your box. The solve call needs only the URL and the sitekey, never a password or session, which is the line we'd tell you to hold with any solver you evaluate.
And injection won't save a badly behaved agent. If your browser fingerprint is so obvious that the target blocks you before Turnstile even loads, a token won't help, because you never reached the gate. Get your agent looking like a normal browser first, then let the solver handle the part that's genuinely hard to fake.
Wiring it into your agent for good
The pattern that holds up over time: wrap the three steps in a single tool your agent can call, detect Turnstile on every page load (not just where you expect it), and solve right before the action that needs the token. Do that and the widget stops being a wall. It becomes one more step your agent handles on its own, the same way it handles a click.
FAQ
Can an AI browser agent solve Cloudflare Turnstile by itself?
Not reliably. A headless agent on a datacenter IP carries the exact signals Turnstile is built to catch, so the widget often refuses to issue a token at all, no matter how the agent tries to interact with it. Fetching a token from a solving API and injecting it is the dependable route for an agent that has to run unattended.
Which frameworks does this apply to?
Any agent driving a real browser context: Browser Use, Stagehand, LangChain browser tools, or a plain Playwright/Puppeteer loop with a model choosing actions. The three-step flow (read sitekey, solve, inject) is framework-agnostic.
Does the LLM need to "see" the Turnstile widget?
No, and it shouldn't try to. The invisible variant renders nothing, so wire the detection check (sitekey or cf-turnstile-response present in the DOM) and the solve into a tool the agent calls, rather than asking the model to visually spot and click a widget.
What's the difference between the proxyless and proxied task types?
turnstiletaskproxyless solves without a proxy and works when the target scores the token independently of the requesting IP. turnstiletask takes a proxy field for sites that bind the token to the IP that solved it, so the solve and the submit come from the same address.
What does this cost?
Peak charges $0.90 per 1,000 successful Turnstile solves, dropping to $0.35 per 1,000 at volume. Failed solves are free, and new accounts get 1,000 free solves with no card.
For the raw browser mechanics behind token injection, see handling Turnstile in Playwright and Puppeteer. For the full case on injection vs. driving the browser yourself, see captcha-solving API vs headless browser.
Start free at peak.fo. 1,000 free solves, no card, pay only for solves that land.
Top comments (0)