Skip to content

Cloudflare Turnstile solver · Playwright

Solve Cloudflare Turnstile in Playwright

When a Playwright script meets a Turnstile widget on a site you are allowed to automate, read the widget's site key, action and cData, get a token from ZeroCaptcha and put it where the widget would. The sample uses the JavaScript client, which is coming to npm; until then, the Node.js page makes the same calls with fetch.

Plain HTTP today; the official JavaScript client is coming

The steps in Playwright

  1. Get an API key

    Sign up with an email and a password, create your key in the dashboard and add funds in crypto, from $10.

  2. Read the widget's site key, action and cData

    Open the page in Playwright and read data-sitekey, data-action and data-cdata from the Turnstile element, or the sitekey, action and cData options of its turnstile.render() call.

  3. Solve it with ZeroCaptcha

    Send the page's URL, the site key, and the action and cData when the widget sets them, to ZeroCaptcha and wait for the token. Many sites check both when they verify it.

  4. Put the token in the form

    Set the cf-turnstile-response field to the token, then submit the form as a person would, within 300 seconds.

New to the API? The quickstart walks through sign-up, the key and the first task.

// Fill a Cloudflare Turnstile widget in Playwright with a token from ZeroCaptcha, on a page you may automate.
// Needs Node.js 20 or later, playwright and @zerocaptcha/sdk:
//   ZEROCAPTCHA_API=https://api.zerocaptcha.io ZEROCAPTCHA_KEY=zc_live_... node fill-turnstile.mjs
import { chromium } from "playwright";
import { TaskFailedError, 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();
// A widget rendered by script takes its settings from turnstile.render(container, options):
// note each call's options before the page's own scripts run.
await page.addInitScript(() => {
  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;
    },
  });
});
await page.goto("https://shop.example.com/login");
await page.waitForFunction(() => document.querySelector("[data-sitekey]") || window.__turnstileRenders);

// The widget's site key, action and cData: its data-sitekey, data-action and data-cdata
// attributes, or the sitekey, action and cData it was rendered with. Many sites check the action
// and cData when they verify the token, so send them exactly as the widget sets them.
const widget = await page.evaluate(() => {
  const element = document.querySelector("[data-sitekey]");
  const rendered = window.__turnstileRenders?.[0] ?? {};
  return {
    websiteKey: element?.dataset.sitekey ?? rendered.sitekey,
    action: element?.dataset.action ?? rendered.action,
    cdata: element?.dataset.cdata ?? rendered.cData,
    callback: element?.dataset.callback,
  };
});

let token;
try {
  // The client sends an Idempotency-Key with the create, so its own retries never make two tasks.
  token = await zerocaptcha.solve({
    websiteURL: page.url(),
    websiteKey: widget.websiteKey,
    action: widget.action, // left out of the request when the widget sets none
    cdata: widget.cdata,
    // proxy: "http://user:pass@proxy.example.net:8080", // to solve through your own proxy
  });
} catch (error) {
  // A task that fails or expires costs nothing; its code says why.
  if (error instanceof TaskFailedError) throw new Error(`Not solved: ${error.code}`);
  throw error;
}

// Put the token where the widget would: its cf-turnstile-response field, then its callback.
await page.evaluate(
  ({ value, callback }) => {
    for (const input of document.querySelectorAll('[name="cf-turnstile-response"]')) input.value = value;
    if (callback && typeof window[callback] === "function") window[callback](value);
  },
  { value: token, callback: widget.callback },
);
await page.getByRole("button", { name: "Log in" }).click();
await browser.close();

Good to know

Read next

Playwright questions

Where does the token go?

Where the widget puts it: the hidden cf-turnstile-response field of the form, or the callback the page registered. Then submit as a person would.

How long does a Cloudflare Turnstile token last?

A Cloudflare Turnstile token works once and expires 300 seconds after it is issued, so solve right before you submit. Every result tells you when its token expires.

Can I test my own site without solving anything?

Yes. Cloudflare publishes testing site keys that always pass or always fail, so your own end-to-end tests need no solver at all.