Was this helpful?
How to Bypass Cloudflare Challenge with Playwright
Technical engineer
Cloudflare Challenge isn't a captcha inside a form — it replaces the target page entirely. Instead of the content, the browser gets a "Verifying you are human" interstitial, and only after the check passes does Cloudflare let it through. For a real user this usually takes a couple of seconds and goes unnoticed. For a Playwright script it's a dead end: page.goto() completes without errors, and the DOM contains none of the selectors you need.
This tutorial shows how to get through the Challenge from Playwright using the 2Captcha API: intercept the widget's dynamic parameters before it initializes, send them for solving, and pass the resulting token to the page's callback function.
Challenge and standalone Turnstile are not the same thing
These two cases are easy to confuse, but they're solved differently.
| Standalone Turnstile | Cloudflare Challenge | |
|---|---|---|
| Where it lives | A .cf-turnstile widget inside a form on a regular page |
A separate interstitial page in place of the target content |
| Task parameters | websiteURL, websiteKey |
websiteURL, websiteKey + action, data, pagedata, userAgent |
| Where to get the sitekey | The data-sitekey attribute in the DOM |
Only by intercepting the turnstile.render call |
| What to do with the token | Set it on input[name="cf-turnstile-response"] or fire the callback |
Fire the callback only |
If you're dealing with the first case, you need How to Bypass Cloudflare Turnstile with Playwright. This tutorial covers the second.
The key difference is that on a Challenge page the cData, chlPageData and action parameters are regenerated on every load, live for a few seconds, and appear nowhere in the markup — they're passed as an argument to the turnstile.render() call. So the only way to get them is to replace turnstile.render itself before Cloudflare calls it.
What you'll need
- Python 3.8+
- Playwright with Chromium installed
- A 2Captcha account and API key (get one here)
- The URL of a Cloudflare-protected page. To try the script out, use the Cloudflare Challenge demo page — the examples below are written against it
- A proxy, if the target site is sensitive to IP reputation (see step 4)
Step 1. Install the dependencies
bash
pip install playwright 2captcha-python
playwright install chromium
Step 2. Launch the browser and prepare the interception
The interceptor must be in the page before the Turnstile script loads. This isn't a recommendation, it's a hard requirement: turnstile.render() is called once, and if you haven't replaced it by then there's nothing left to intercept — the parameters go inside the widget and stay out of reach. This is exactly where most scripts break: the code is injected with page.evaluate() after page.goto(), and it's consistently too late.
Playwright has a built-in mechanism for this — add_init_script(). It runs your code in every new document before any of the page's own scripts, including Cloudflare's.
Start with the browser context. While you're debugging, keep headless=False — it's much easier to see what's actually happening on the page.
python
from playwright.sync_api import sync_playwright
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context(user_agent=USER_AGENT, locale="en-US")
page = context.new_page()
Set the User-Agent explicitly and keep it around: the string you send with the solving task must match the one your browser uses to open the site. Cloudflare factors the User-Agent into its assessment, and a mismatch makes the token useless.
Step 3. Intercept the Challenge parameters
Inject the init script. It waits for the window.turnstile object to appear, replaces its render method, grabs the call arguments, and stores the callback in the global window.tsCallback — that callback is what will receive the token later.
python
INTERCEPT_SCRIPT = """
const i = setInterval(() => {
if (window.turnstile) {
clearInterval(i);
window.turnstile.render = (a, b) => {
const params = {
websiteKey: b.sitekey,
websiteURL: window.location.href,
data: b.cData,
pagedata: b.chlPageData,
action: b.action,
userAgent: navigator.userAgent
};
window.tsCallback = b.callback;
window.onChallengeParams(JSON.stringify(params));
return;
};
}
}, 50);
"""
The original render is called by Cloudflare's code with two arguments: the container selector and a settings object. The second one is what matters — that's where sitekey, cData, chlPageData and action come from, along with the reference to callback. The widget itself never renders: the replacement function simply returns.
Now the intercepted parameters need to travel from the page context back to Python. page.expose_function() exposes a real Python function to the page that JS can call directly — more reliable than parsing console messages:
python
import json
challenge_params = {}
def on_challenge_params(raw):
challenge_params.update(json.loads(raw))
print("Parameters intercepted:", challenge_params["action"])
page.expose_function("onChallengeParams", on_challenge_params)
page.add_init_script(INTERCEPT_SCRIPT)
page.goto(TARGET_URL)
page.wait_for_function("() => window.tsCallback !== undefined", timeout=30000)
The order matters: expose_function and add_init_script both come before goto, otherwise the whole thing is pointless.
wait_for_function is safer here than time.sleep(): the script resumes at the exact moment Cloudflare actually called render and the callback became available.
Important: from this point until you submit the token, don't reload the page or close the tab.
cDataandchlPageDataare single-use and tied to that specific document load. Reload, and every intercepted value turns to garbage — the API will returnERROR_CAPTCHA_UNSOLVABLE.
Step 4. Send the task for solving
Challenge uses the same task type as regular Turnstile, but with additional fields. The full request looks like this:
json
{
"clientKey": "YOUR_API_KEY",
"task": {
"type": "TurnstileTaskProxyless",
"websiteURL": "https://2captcha.com/demo/cloudflare-turnstile-challenge",
"websiteKey": "0x4AAAAAAAAjq6WYeRDKmebM",
"action": "managed",
"data": "80001aa1affffc21",
"pagedata": "3gAFo2l...55NDFPRFE9",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..."
}
}
There are two task types:
- TurnstileTaskProxyless — solved through the service's own proxy pool. Fine when the target site doesn't tie the result to an IP.
- TurnstileTask — solved through your proxy. Additionally takes
proxyType,proxyAddress,proxyPort,proxyLogin,proxyPassword. Use this one if the Challenge is served aggressively or the site checks IP strictly.
A practical rule: start with proxyless. If tokens are consistently rejected, switch to TurnstileTask and pass the same proxy Playwright itself runs through.
With the official SDK, creating the task and polling for the result collapse into a single call:
python
from twocaptcha import TwoCaptcha
solver = TwoCaptcha("YOUR_API_KEY")
result = solver.turnstile(
sitekey=challenge_params["websiteKey"],
url=challenge_params["websiteURL"],
action=challenge_params["action"],
data=challenge_params["data"],
pagedata=challenge_params["pagedata"],
useragent=challenge_params["userAgent"],
)
token = result["code"]
One detail worth an hour of debugging: the parameter is useragent, one word — not user_agent. The spelling matters, because extra arguments are forwarded to the API as-is, so a typo won't raise an error. The User-Agent simply never reaches the solver, and the token comes back unusable.
Solving usually takes 10–25 seconds.
Step 5. Inject the token through the callback
This is where Challenge differs fundamentally from a standalone widget. Writing the token into a hidden input isn't enough and is usually meaningless: there's no form on the interstitial for you to submit. The token has to reach the callback — that's what delivers the verification result to Cloudflare, after which the browser lands on the original page.
We stored that callback in window.tsCallback back in step 3, so the call is a single line:
python
page.evaluate("(token) => window.tsCallback(token)", token)
Then wait for Cloudflare to release the browser. Key off the target content appearing rather than a fixed timeout:
python
page.wait_for_load_state("networkidle")
page.wait_for_selector("#content", timeout=30000)
Substitute your own selector — any element that definitely isn't on the verification page.
Full working example
python
import json
from playwright.sync_api import sync_playwright
from twocaptcha import TwoCaptcha
API_KEY = "YOUR_API_KEY"
TARGET_URL = "https://2captcha.com/demo/cloudflare-turnstile-challenge"
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
INTERCEPT_SCRIPT = """
const i = setInterval(() => {
if (window.turnstile) {
clearInterval(i);
window.turnstile.render = (a, b) => {
const params = {
websiteKey: b.sitekey,
websiteURL: window.location.href,
data: b.cData,
pagedata: b.chlPageData,
action: b.action,
userAgent: navigator.userAgent
};
window.tsCallback = b.callback;
window.onChallengeParams(JSON.stringify(params));
return;
};
}
}, 50);
"""
solver = TwoCaptcha(API_KEY)
params = {}
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
context = browser.new_context(user_agent=USER_AGENT, locale="en-US")
page = context.new_page()
page.expose_function("onChallengeParams", lambda raw: params.update(json.loads(raw)))
page.add_init_script(INTERCEPT_SCRIPT)
page.goto(TARGET_URL)
page.wait_for_function("() => window.tsCallback !== undefined", timeout=30000)
print("Parameters intercepted:", params.get("action"))
result = solver.turnstile(
sitekey=params["websiteKey"],
url=params["websiteURL"],
action=params.get("action"),
data=params.get("data"),
pagedata=params.get("pagedata"),
useragent=params["userAgent"],
)
token = result["code"]
print("Token received")
page.evaluate("(token) => window.tsCallback(token)", token)
page.wait_for_load_state("networkidle")
print("Current URL:", page.url)
browser.close()
Solution accuracy reports
If the site rejected the token, send reportIncorrect — it refunds the failed attempt and helps the service improve its algorithms. On success, send reportCorrect.
python
import requests
API_KEY = "YOUR_API_KEY"
TASK_ID = "123456789"
requests.post("https://api.2captcha.com/reportIncorrect",
json={"clientKey": API_KEY, "taskId": TASK_ID})
requests.post("https://api.2captcha.com/reportCorrect",
json={"clientKey": API_KEY, "taskId": TASK_ID})
A detailed walkthrough with examples in different languages, submission limits, and a breakdown of the ERROR_REPORT_NOT_RECORDED and ERROR_DUPLICATE_REPORT errors is in the How to submit reports guide.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Intercepted parameters come back empty | The script was injected after Turnstile initialized | Use add_init_script() before goto(), not evaluate() after |
ERROR_CAPTCHA_UNSOLVABLE |
data / pagedata are stale or empty |
Don't reload the page between interception and submission. Send the task as soon as expose_function fires |
| Token received, but the page doesn't change | The callback wasn't fired, or the wrong one was | Call the stored window.tsCallback(token) specifically |
Works with headless=False, fails with True |
Cloudflare also scores the browser fingerprint | Add playwright-extra with a stealth plugin, a realistic UA, and a residential proxy |
| Token rejected despite correct parameters | User-Agent mismatch between the browser and the task | Use the same UA string in new_context() and in the useragent parameter |
ERROR_WRONG_USER_KEY |
Invalid API key | Check the key in your dashboard |
| Element lookups fail after passing the check | The site changed its markup | Refresh your selectors in DevTools and use Playwright's expect waits |