# 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.

- Source: https://zerocaptcha.io/blog/nodriver-cloudflare-turnstile
- Published: 2026-10-01
- Author: ZeroCaptcha Engineering

**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](https://zerocaptcha.io/guides/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`:

```python
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](https://zerocaptcha.io/guides/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](https://zerocaptcha.io/guides/cloudflare-turnstile-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](https://zerocaptcha.io/guides/submit-cloudflare-turnstile-token)
  covers the cases.

## zendriver

The same script runs on zendriver with three changes:

```python
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](https://zerocaptcha.io/guides/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](https://zerocaptcha.io/cloudflare-challenge-solver) and
[Cloudflare Turnstile in headless browsers](https://zerocaptcha.io/blog/cloudflare-turnstile-headless-browser). More
Python examples are on the [Cloudflare Turnstile solver for Python](https://zerocaptcha.io/cloudflare-turnstile-solver/python)
page.

## Sources

- [nodriver](https://github.com/ultrafunkamsterdam/nodriver) and its
  [Tab reference](https://ultrafunkamsterdam.github.io/nodriver/nodriver/classes/tab.html) and
  [quickstart](https://ultrafunkamsterdam.github.io/nodriver/nodriver/quickstart.html)
  (checked 1 October 2026).
- [zendriver](https://github.com/cdpdriver/zendriver) (checked 1 October 2026).
- [Cloudflare challenges: supported browsers](https://developers.cloudflare.com/cloudflare-challenges/reference/supported-browsers/)
  (checked 1 October 2026).
- [Cloudflare Turnstile: client-side rendering](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/)
  (checked 1 October 2026).

## 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.
