How to Handle Cloudflare Turnstile in AI Browser Agents (Browser Use, Stagehand, Playwright)

Your AI browser agent stalls on Cloudflare Turnstile? Here's why, plus Python and JS code to solve and inject a valid token into Browser Use or Playwright.

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.

Here's what's actually happening and how to get your agent past it, with code you can drop in today. This is 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. When Turnstile is satisfied, it writes a value into a hidden input named cf-turnstile-response and fires a JavaScript callback. 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. For deeper background on the token itself, we wrote up what the cf-turnstile-response token is and how validation works.

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. When it works, the token is produced inside your own session with zero external calls. When it doesn't, you're stuck tuning fingerprints against a detection system that updates on its own schedule. 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. We laid out the full comparison in captcha-solving API vs headless browser if you want the long version.

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.001}
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. We go deeper on the raw browser mechanics in handling Turnstile in Playwright and Puppeteer.

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);

There are copy-paste starting points for both languages at the Python Turnstile guide and the JavaScript Turnstile guide.

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.

The request looks like this when you bring your own:

{
  "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.

For the wider picture of every Turnstile approach and where each one fits, our 2026 developer guide to solving Cloudflare Turnstile goes through all of them.

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.

You can test the whole flow on 1,000 free solves with no card. Pricing runs from $1 per 1,000 solves down to $0.35 at volume, and you pay only when a solve succeeds. Grab a key and check the Turnstile solving pricing, then drop the three steps above into your agent and let it run.