Test Cloudflare Turnstile in Playwright.
Zero runtime dependencies. Talks to no service. Works with no account.
Every Turnstile E2E suite reimplements the same four things, usually badly:
- Flaky waits.
waitForTimeout(3000)passes locally and fails in CI, because Turnstile's latency tracks the visitor's risk score and runner egress scores worse than your laptop. - Finding the sitekey. Implicit render puts it in
data-sitekey. Explicit render passes it as a JS argument toturnstile.render(), where no selector will find it. - Widget vs. WAF interstitial. A widget sits in your form and yields a token. A
/cdn-cgi/challenge-platforminterstitial replaces the page and has no form to fill. Suites that conflate them wait forever for an element that was never coming. - The callback. Setting
input.valueis not enough — a form that enables its submit button fromdata-callbackstays disabled, and your test fails one step later with a misleading error.
npm i -D playwright-turnstileimport { test as base } from "@playwright/test";
import { turnstileFixture } from "playwright-turnstile/fixture";
const test = base.extend(turnstileFixture());
test("signs in through Turnstile", async ({ page, turnstile }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("qa@example.com");
await turnstile.waitForToken(); // no arbitrary sleep
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/dashboard/);
});If you control the app under test, most suites should stop reading here.
Cloudflare publishes dummy keys that work on any domain including localhost, resolve
instantly, cost nothing and never touch the network. Point your staging build at one and the
widget stops being a problem.
| Key | Type | Behaviour |
|---|---|---|
1x00000000000000000000AA |
sitekey, visible | Always passes |
1x00000000000000000000BB |
sitekey, invisible | Always passes |
2x00000000000000000000AB |
sitekey, visible | Always fails |
2x00000000000000000000BB |
sitekey, invisible | Always fails |
3x00000000000000000000FF |
sitekey, visible | Forces an interactive challenge |
1x0000000000000000000000000000000AA |
secret | Always passes validation |
2x0000000000000000000000000000000AA |
secret | Always fails validation |
3x0000000000000000000000000000000AA |
secret | Returns timeout-or-duplicate |
Exported for convenience:
import { TEST_SITEKEYS, TEST_SECRETS } from "playwright-turnstile";Swap both halves — a test secret rejects real tokens and a live secret rejects the dummy
token, and a mismatched pair fails siteverify in a way that sends you hunting the wrong
bug. Use alwaysBlocks deliberately to cover your error path, and forcesInteractive to
check your layout survives a challenge.
If you cannot redeploy staging, rewrite the key in the browser instead:
await page.route("**/login", async (route) => {
const res = await route.fetch();
const body = (await res.text()).replace(/data-sitekey="[^"]*"/g, `data-sitekey="${TEST_SITEKEYS.alwaysPasses}"`);
await route.fulfill({ response: res, body });
});This only works if the server verifying the token also runs a test secret. Both halves have to agree.
Every function takes a Playwright Page as its first argument.
| Function | Returns |
|---|---|
detect(page) |
{ sitekey, action?, cData?, responseField, explicit } or null |
sitekeyOf(page) |
The sitekey, or null |
tokenOf(page, field?) |
Current token, or "" |
waitForToken(page, { timeout?, responseField? }) |
The token once populated |
injectToken(page, token, { responseField?, callback? }) |
— sets value, fires events and the callback |
isWafChallenge(page) |
true for a /cdn-cgi/challenge-platform interstitial |
expectTurnstileSolved(page) |
The token, or throws explaining why not |
solveAndInject(page, solver) |
Detect → solve via your adapter → inject |
The turnstile fixture exposes the same surface as methods, bound to the page.
For the cases test keys cannot cover — a staging environment whose Turnstile configuration you do not control, or synthetic monitoring that must exercise the real production path.
A solver is one function. Nothing here is tied to a vendor, and there is no default:
export type SolverAdapter = (input: {
sitekey: string;
url: string;
action?: string;
cData?: string;
}) => Promise<string>; // resolves to the tokenWrite one against any provider. Here is a complete adapter for SolveGate:
const solveGate: SolverAdapter = async ({ sitekey, url, action }) => {
const r = await fetch("https://api.solvegate.io/v1/solve", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.SOLVEGATE_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ gate: "turnstile", sitekey, url, action }),
});
if (!r.ok) throw new Error(`solve failed: ${r.status}`);
return (await r.json()).token;
};
const test = base.extend(turnstileFixture({ solver: solveGate }));
// then, in a test: await turnstile.solve();That is the whole integration. Swap the URL and header for any other service and it works the same way.
GitHub Actions — prefer test keys in CI; keep any solver key in secrets.
- run: npx playwright test
env:
TURNSTILE_SITEKEY: "1x00000000000000000000AA"
TURNSTILE_SECRET: "1x0000000000000000000000000000000AA"Assert the failure path — point staging at 2x00000000000000000000AB and check your app
shows the right error rather than hanging.
Synthetic monitoring — solveAndInject against production on a schedule, then assert the
post-login page renders.
See cloudflare-turnstile-e2e-example
for a complete runnable pipeline.
waitForToken times out. Check isWafChallenge(page) first — if it is true you are on
an interstitial and no token is coming. Otherwise the widget may have errored; Turnstile
reports client errors through error-callback as numeric codes such as 300030.
Submit stays disabled after injecting. The page uses a callback you have not named. Pass
it: injectToken(page, token, { callback: "onTurnstileSuccess" }).
Token rejected as timeout-or-duplicate. Tokens are single-use and valid for 300
seconds. Request one at the moment you submit, and never cache.
Works headed, fails headless. Expected — headless egress scores worse. Use test sitekeys in CI rather than fighting it.
For testing and monitoring properties you own or are explicitly authorised to test. Do not point a solver at third-party sites in breach of their terms.
Issues and PRs welcome. npm test runs the suite against real Chromium via
page.setContent() — no network, no Cloudflare script, no account.
MIT