Tutorial
nodriver and zendriver With Cloudflare Turnstile: A Tutorial
Handle a Cloudflare Turnstile form in nodriver or zendriver: read the widget, get a token from an API, fill the field, call the callback and submit, all async.
By ZeroCaptcha Engineering5 min readPublished
nodriver, the successor to undetected-chromedriver, and its fork zendriver drive Chrome
directly over the DevTools protocol, without WebDriver or Selenium. That removes some automation
signals, but it does not make a Cloudflare Turnstile widget pass: Cloudflare says automated browsers
“are not supported for solving production challenges”. The dependable way to handle a Turnstile form
in nodriver is the same as in any browser tool: read the widget’s sitekey (and action and cData),
get a token from a solving API, write it into the cf-turnstile-response field, call the widget’s
callback if the page uses one, and submit, all inside nodriver’s async loop.
This tutorial shows the whole script with nodriver, then what changes for zendriver. Automate only sites you are allowed to: see responsible captcha automation.
What nodriver and zendriver are
- nodriver describes itself as “next level async webscraping and browser automation library
for python”, the official successor to undetected-chromedriver, with no WebDriver or Selenium
dependency. It works with Chromium, Chrome, Edge and Brave. Install it with
pip install nodriver. Its licence is AGPL-3.0. - zendriver is “a fork of the
nodriverproject” made to merge pending bug fixes, add static analysis withruffandmypy, and take community contributions. Install it withpip install zendriver. It is AGPL-3.0 too.
Both are async: every call is awaited, and a page is a “tab” you select elements in and
evaluate JavaScript on.
The script
It needs Python 3.10 or later. Install nodriver and httpx, set ZEROCAPTCHA_API and
ZEROCAPTCHA_KEY, and save this as login.py:
import asyncioimport jsonimport osimport uuid
import httpximport nodriver as uc
API = os.environ["ZEROCAPTCHA_API"]KEY = os.environ["ZEROCAPTCHA_KEY"]PAGE = "https://shop.example.com/login"
READ_WIDGET = """(() => { const widget = document.querySelector("form#login [data-sitekey]"); return widget ? JSON.stringify({ sitekey: widget.dataset.sitekey, action: widget.dataset.action || null, cdata: widget.dataset.cdata || null, callback: widget.dataset.callback || null, }) : null;})()"""
async def solve_turnstile(page_url, widget): task = {"type": "TurnstileTaskProxyless", "websiteURL": page_url, "websiteKey": widget["sitekey"], "metadata": {}} # The widget's data-action and data-cdata go in metadata, only when it sets them. if widget["action"]: task["metadata"]["action"] = widget["action"] if widget["cdata"]: task["metadata"]["cdata"] = widget["cdata"] async with httpx.AsyncClient(base_url=API, timeout=15) as client: # One Idempotency-Key per task: a retried create with it returns the same task. created = (await client.post("/createTask", json={"clientKey": KEY, "task": task}, headers={"Idempotency-Key": str(uuid.uuid4())})).json() if created["errorId"]: raise RuntimeError(f"createTask: {created['errorCode']}") for _ in range(90): # 90 polls, 2 seconds apart: 3 minutes at most await asyncio.sleep(2) reply = await client.post("/getTaskResult", json={"clientKey": KEY, "taskId": created["taskId"]}) result = reply.json() if result["errorId"]: raise RuntimeError(f"getTaskResult: {result['errorCode']}") if result["status"] == "ready": return result["solution"]["token"] raise TimeoutError("no token within 180 seconds")
def fill_script(token, callback): return ( "((token, callbackName) => {" " for (const input of document.querySelectorAll('[name=\"cf-turnstile-response\"]')) input.value = token;" " const callback = callbackName && window[callbackName];" " if (typeof callback === 'function') callback(token);" "})(" + json.dumps(token) + ", " + json.dumps(callback) + ")" )
async def main(): browser = await uc.start() tab = await browser.get(PAGE) await tab.select("form#login [data-sitekey]") # waits up to 10 seconds for the widget
raw = await tab.evaluate(READ_WIDGET, return_by_value=True) if not raw: raise SystemExit("no Cloudflare Turnstile widget in form#login") widget = json.loads(raw)
email = await tab.select("form#login input[name=email]") await email.send_keys(os.environ["LOGIN_EMAIL"]) password = await tab.select("form#login input[name=password]") await password.send_keys(os.environ["LOGIN_PASSWORD"])
token = await solve_turnstile(PAGE, widget) await tab.evaluate(fill_script(token, widget["callback"])) submit = await tab.select("form#login button[type=submit]") await submit.click() await tab.sleep(3) print((await tab.get_content())[:500]) browser.stop()
if __name__ == "__main__": uc.loop().run_until_complete(main())Run it with python login.py.
How it works
tab.selectwaits. It finds one element by CSS selector and “can also be used to wait for such element to appear”, for 10 seconds by default. Waiting for the widget before reading it matters on pages that render it late.- Read the widget as JSON.
tab.evaluate(expression, await_promise=False, return_by_value=False)runs JavaScript in the page;return_by_value=Truegives back a plain value. Returning one JSON string keeps the result simple whatever nodriver version you run. - Solve after filling the form. A Cloudflare Turnstile token is valid for 300 seconds and works once. Asking for it just before you submit keeps the gap short; see Cloudflare Turnstile token expiry.
- Send action and cData when the page sets them. The site’s server sees both after siteverify and may refuse a token whose action does not match. See action and cData.
- Fill the field, then call the callback. In a real browser the widget fills
cf-turnstile-responseand calls the page’sdata-callback. The script does the same two things with the token from the API. Pages that pass an inline function toturnstile.renderneed that function called instead: submit a Cloudflare Turnstile token covers the cases.
zendriver
The same script runs on zendriver with three changes:
import asyncio
import zendriver as zd
async def main(): browser = await zd.start() tab = await browser.get("https://shop.example.com/login") await tab.select("form#login [data-sitekey]") # ... the same steps as in the nodriver script ... await browser.stop()
if __name__ == "__main__": asyncio.run(main())Import zendriver as zd, start it with zd.start(), run main with asyncio.run, and await
browser.stop() as zendriver’s README does.
Proxies and the token
nodriver can give each browser context its own proxy with browser.create_context(proxy_server=…).
If the site checks that the token’s solver and the form’s sender share an address, solve through
the same proxy: use TurnstileTask with a proxy field instead of TurnstileTaskProxyless. See
solve Cloudflare Turnstile with a proxy.
ZeroCaptcha takes HTTP and HTTPS proxies; SOCKS proxies are not supported.
When the page is a challenge, not a widget
If every page answers “Just a moment…” before any form appears, you are looking at a Cloudflare challenge page, not a Turnstile widget: a token does not help there. Use a challenge task, which returns the cf_clearance cookie with the user agent it is bound to, earned through your proxy. See the Cloudflare WAF and 5-second challenge solver and Cloudflare Turnstile in headless browsers. More Python examples are on the Cloudflare Turnstile solver for Python page.
Sources
- nodriver and its Tab reference and quickstart (checked 1 October 2026).
- zendriver (checked 1 October 2026).
- Cloudflare challenges: supported browsers (checked 1 October 2026).
- Cloudflare Turnstile: client-side rendering (checked 1 October 2026).
The team that builds and runs the ZeroCaptcha API. Articles are drafted with AI tools, then checked against the API's code and the primary sources each one cites.