Skip to content

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 5 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 nodriver project” made to merge pending bug fixes, add static analysis with ruff and mypy, and take community contributions. Install it with pip 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 asyncio
import json
import os
import uuid
import httpx
import 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.select waits. 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=True gives 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-response and calls the page’s data-callback. The script does the same two things with the token from the API. Pages that pass an inline function to turnstile.render need 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

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.

Questions

Does nodriver bypass Cloudflare Turnstile?

Not reliably. nodriver drives Chrome over the DevTools protocol without WebDriver, which removes some automation signals, but Cloudflare says automated browsers are not supported for solving production challenges. Treat the widget as something to fill with a token, not something the browser will pass.

What is the difference between nodriver and zendriver?

zendriver is a fork of nodriver that merges pending bug fixes, adds static analysis and takes community contributions. The API is close: import zendriver as zd instead of nodriver as uc, and run it with asyncio.run.

How do I put a Cloudflare Turnstile token into the page with nodriver?

Run JavaScript in the page with tab.evaluate: set the value of every cf-turnstile-response input to the token, then call the widget's data-callback function with it if the page names one.

Read next

This article is part of the Cloudflare Turnstile solver hub. Every task is charged only when a token is ready.

Get an API key