Skip to content
ZeroCaptcha

Browser automation

When your automation drives a real browser, the flow is the same in every tool:

  1. Open the page and read the widget’s site key (and its action and cData, if set) from it.
  2. Solve it through ZeroCaptcha from your script, as in Solving Cloudflare Turnstile.
  3. Put the token into the page: the cf-turnstile-response field, and the widget’s callback if the page uses one.
  4. Submit as a person would, within 300 seconds of the token being issued.

The key stays in your script’s environment. Never put it into the page: the page, and anything running in it, could read it.

Send the widget’s action and cData with its site key whenever it sets them: many sites check both when they verify the token, and refuse one solved without them. See action and cData.

A widget written in the HTML carries all three as data-sitekey, data-action and data-cdata. A widget rendered by script gets them as the sitekey, action and cData options of turnstile.render(), which leave no attribute behind, so note each call’s options before the page’s own scripts run (Playwright’s addInitScript, Puppeteer’s evaluateOnNewDocument, or Chrome’s Page.addScriptToEvaluateOnNewDocument from Selenium or chromedp):

// Runs before the page's scripts: keeps the options of every turnstile.render() call.
() => {
let api;
Object.defineProperty(window, "turnstile", {
configurable: true,
get: () => api,
set(value) {
const render = value.render;
value.render = (container, options = {}) => {
window.__turnstileRenders = [...(window.__turnstileRenders ?? []), options];
return render.call(value, container, options);
};
api = value;
},
});
};

This runs in the page once it has loaded, and returns the widget’s values from either place:

() => {
const widget = document.querySelector("[data-sitekey]");
const rendered = window.__turnstileRenders?.[0] ?? {};
return {
websiteKey: widget?.dataset.sitekey ?? rendered.sitekey,
action: widget?.dataset.action ?? rendered.action ?? null,
cdata: widget?.dataset.cdata ?? rendered.cData ?? null,
callback: widget?.dataset.callback ?? null,
};
};

Then solve with the values it found and the page’s URL. With the SDKs, that is one call; with plain HTTP, create the task and poll it as in Solving Cloudflare Turnstile:

Terminal window
# websiteKey, action and cdata as the page function found them; leave out action or cdata when it
# found none. For your own proxy, make the type TurnstileTask and add "proxy"; to be called when
# the task ends, add "callbackUrl". The reply is the task, or a problem document saying why not.
curl -sS --fail-with-body "$ZEROCAPTCHA_API/v1/tasks" \
-H "Authorization: Bearer $ZEROCAPTCHA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"type": "TurnstileTaskProxyless", "websiteURL": "https://example.com/login",
"websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "action": "login", "cdata": "session-7f3a9c2e"}'

This also runs in the page, with the token as its argument. It fills every cf-turnstile-response field and calls the widget’s callback, if the page named one:

(token) => {
for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token;
const name = document.querySelector("[data-callback]")?.dataset.callback;
if (name && typeof window[name] === "function") window[name](token);
};
import { chromium } from "playwright";
import { ZeroCaptcha } from "@zerocaptcha/sdk";
const zerocaptcha = new ZeroCaptcha({ apiKey: process.env.ZEROCAPTCHA_KEY, baseUrl: process.env.ZEROCAPTCHA_API });
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://example.com/login");
const widget = page.locator("[data-sitekey]").first();
await widget.waitFor({ state: "attached" });
const token = await zerocaptcha.solve({
websiteURL: page.url(),
websiteKey: await widget.getAttribute("data-sitekey"),
action: (await widget.getAttribute("data-action")) ?? undefined,
cdata: (await widget.getAttribute("data-cdata")) ?? undefined,
});
await page.evaluate((value) => {
for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = value;
const name = document.querySelector("[data-callback]")?.dataset.callback;
if (name && typeof window[name] === "function") window[name](value);
}, token);
await page.fill("#email", "you@example.com");
await page.click("button[type=submit]");
await browser.close();
import puppeteer from "puppeteer";
import { ZeroCaptcha } from "@zerocaptcha/sdk";
const zerocaptcha = new ZeroCaptcha({ apiKey: process.env.ZEROCAPTCHA_KEY, baseUrl: process.env.ZEROCAPTCHA_API });
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("https://example.com/login", { waitUntil: "domcontentloaded" });
await page.waitForSelector("[data-sitekey]");
const widget = await page.$eval("[data-sitekey]", (element) => ({ ...element.dataset }));
const token = await zerocaptcha.solve({
websiteURL: page.url(),
websiteKey: widget.sitekey,
action: widget.action,
cdata: widget.cdata,
});
await page.evaluate((value) => {
for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = value;
const name = document.querySelector("[data-callback]")?.dataset.callback;
if (name && typeof window[name] === "function") window[name](value);
}, token);
await page.type("#email", "you@example.com");
await page.click("button[type=submit]");
await browser.close();
import os
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
from zerocaptcha import ZeroCaptcha
INJECT = """
const token = arguments[0];
for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token;
const name = document.querySelector("[data-callback]")?.dataset.callback;
if (name && typeof window[name] === "function") window[name](token);
"""
zerocaptcha = ZeroCaptcha(api_key=os.environ["ZEROCAPTCHA_KEY"], base_url=os.environ["ZEROCAPTCHA_API"])
driver = webdriver.Chrome()
try:
driver.get("https://example.com/login")
widget = WebDriverWait(driver, 15).until(
ec.presence_of_element_located((By.CSS_SELECTOR, "[data-sitekey]"))
)
token = zerocaptcha.solve(
website_url=driver.current_url,
website_key=widget.get_attribute("data-sitekey"),
action=widget.get_attribute("data-action"),
cdata=widget.get_attribute("data-cdata"),
)
driver.execute_script(INJECT, token)
driver.find_element(By.ID, "email").send_keys("you@example.com")
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
finally:
driver.quit()
package main
import (
"context"
"encoding/json"
"log"
"os"
"github.com/chromedp/chromedp"
zerocaptcha "github.com/zerocaptcha/zerocaptcha-go"
)
const inject = `(token) => {
for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = token;
const name = document.querySelector("[data-callback]")?.dataset.callback;
if (name && typeof window[name] === "function") window[name](token);
}`
func main() {
client, err := zerocaptcha.NewClient(os.Getenv("ZEROCAPTCHA_KEY"), os.Getenv("ZEROCAPTCHA_API"))
if err != nil {
log.Fatal(err)
}
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
// The widget's site key, action and cData; an attribute the widget does not set stays empty,
// and an empty Action or CData is left out of the task.
var siteKey, action, cdata, pageURL string
var found bool
err = chromedp.Run(ctx,
chromedp.Navigate("https://example.com/login"),
chromedp.WaitReady("[data-sitekey]"),
chromedp.AttributeValue("[data-sitekey]", "data-sitekey", &siteKey, &found),
chromedp.AttributeValue("[data-sitekey]", "data-action", &action, &found),
chromedp.AttributeValue("[data-sitekey]", "data-cdata", &cdata, &found),
chromedp.Location(&pageURL),
)
if err != nil {
log.Fatal(err)
}
token, err := client.Solve(ctx, zerocaptcha.NewTask{
WebsiteURL: pageURL,
WebsiteKey: siteKey,
Action: action, // many sites check both when they verify the token
CData: cdata,
})
if err != nil {
log.Fatal(err)
}
err = chromedp.Run(ctx,
chromedp.Evaluate("("+inject+")("+quote(token)+")", nil),
chromedp.SendKeys("#email", "you@example.com"),
chromedp.Click("button[type=submit]"),
)
if err != nil {
log.Fatal(err)
}
}
// quote makes a JavaScript string literal of s.
func quote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
  • Solve late, submit at once. A token lasts 300 seconds and works once. Fill the rest of the form first, then solve, inject and submit.
  • The widget may overwrite the field. If the page’s own widget finishes after you inject, it replaces your token with its own. Inject right before submitting, and submit in the same step.
  • Use the page’s real URL as websiteURL, after any redirect, and the same action and cdata the widget has.
  • Challenge pages are different. A “Just a moment…” page before the site loads is a challenge page: solve it with a challenge task through the proxy your browser uses, and set its cf_clearance cookie and user agent in the browser before you load the page.