Cloudflare Turnstile in Playwright tests: what test keys cover and when you need real keys
Put a Cloudflare Turnstile widget on a signup form and your Playwright suite will start failing in ways that have nothing to do with your code. Cloudflare's own testing docs say it directly: automated suites such as Sele
Put a Cloudflare Turnstile widget on a signup form and your Playwright suite will start failing in ways that have nothing to do with your code. Cloudflare's own testing docs say it directly: automated suites such as Selenium, Cypress and Playwright are detected as bots. The widget may block, show a challenge, or behave differently from run to run.
Cloudflare's answer is a set of dummy keys. They solve most of the problem. This post covers how to wire them into a Playwright setup, which tests they make possible, and the handful of checks they can't do, where you need your real keys.
Everything here assumes you are testing your own site or a staging copy of it.
The dummy keys, and why you need both halves
Turnstile has two keys: the sitekey in your HTML and the secret key your backend sends to Siteverify. Cloudflare publishes test values for both.
Sitekeys:
-
1x00000000000000000000AA: always passes, visible widget -
2x00000000000000000000AB: always fails, visible widget -
1x00000000000000000000BB/2x00000000000000000000BB: pass / fail, invisible widget -
3x00000000000000000000FF: forces an interactive challenge
Secret keys:
-
1x0000000000000000000000000000000AA: validation always passes -
2x0000000000000000000000000000000AA: validation always fails -
3x0000000000000000000000000000000AA: returnstimeout-or-duplicate, as if the token was already spent
The detail that catches people: test sitekeys produce the dummy token XXXX.DUMMY.TOKEN.XXXX, and production secret keys reject it. Swap only the sitekey and your form will fail on the server with no obvious reason. Swap both, always together.
Test keys also work on any hostname, including localhost and 127.0.0.1. Your real sitekey is a different story: Cloudflare recommends that production sitekeys don't allow local domains at all.
Wiring keys per environment
Keep the keys in environment variables and let the test runner choose them. Playwright's webServer option starts your app before the tests and accepts an env block, so the suite can run the app with test keys without touching your .env files:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
const PASS = {
TURNSTILE_SITEKEY: "1x00000000000000000000AA",
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA",
};
export default defineConfig({
use: { baseURL: "http://localhost:3000" },
webServer: {
command: "npm run start",
url: "http://localhost:3000",
env: { ...process.env, ...PASS } as Record<string, string>,
// Leave this false for captcha tests. See below.
reuseExistingServer: false,
},
});
That last line matters. The Playwright docs suggest reuseExistingServer: !process.env.CI so a local dev server gets reused. That's convenient, but a dev server you already started has whatever keys it was started with, and the env block is never applied. On your laptop the tests then hit the real sitekey and fail, while CI passes. If you want reuse, have the app print which sitekey it loaded at startup so the mismatch is visible.
The tests worth writing
With the always-pass pair, the widget produces the dummy token on its own, so the test is just "fill the form, wait for the token, submit". The token lands in a hidden input named cf-turnstile-response that Turnstile adds inside your <form>:
// signup.spec.ts
import { test, expect } from "@playwright/test";
const tokenField = 'input[name="cf-turnstile-response"]';
test("signup succeeds when Turnstile passes", async ({ page }) => {
await page.goto("/signup");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("a-long-test-password");
// The widget can load lazily: wait for it to write a token.
await expect
.poll(() => page.locator(tokenField).inputValue(), { timeout: 15_000 })
.not.toBe("");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText("Check your inbox")).toBeVisible();
});
Then cover the failure branches. Each needs a different key pair, and since the keys are read when the server boots, the simplest structure is one Playwright project (or one CI job) per pair:
-
Widget fails (
2x…ABsitekey +2x…AAsecret). Does the form show a useful message, keep what the user typed, and offer a retry? Many forms just disable the submit button forever. -
Server rejects (
1x…AAsitekey +2x…AAsecret). The browser side looks fine, so this is the test that proves your backend actually checkssuccessinstead of trusting that a token exists. -
Token already spent (
1x…AAsitekey +3x…AAsecret). Your code should treattimeout-or-duplicateas "get a fresh token", for example by callingturnstile.reset()and asking the user to try again, rather than showing a generic error. -
Interactive challenge (
3x…FFsitekey +1x…AAsecret). Useful for a quick manual check of layout when the challenge appears. In an unattended suite it just waits for a human, so keep it out of CI.
What test keys cannot tell you
Dummy keys exercise your code paths. They don't exercise your Turnstile configuration, and a few things only show up with real keys:
-
Hostname checks. A successful validation with a test secret always reports
example.comas the hostname. If your backend compareshostnamewith your own domain (a good idea), that check fails under test keys, or you end up skipping it in test mode, which means it's never tested. -
actionandcdata. The test-key response doesn't include them. If you tag widgets withdata-action="signup"and verify the action on the server, only real keys show that the value round-trips. -
Hostname management. Whether your production sitekey actually allows
www.and the apex domain, or your preview domains, is a dashboard setting. No dummy key can catch a missing entry. -
Expiry and reuse timing. Real tokens are valid for 300 seconds and meant for one validation. A slow multi-step form can outlive a token. The
3xsecret simulates the error, not the timing.
Some of this is better handled below the browser. Playwright's page.route() only intercepts requests the browser makes, and Siteverify is called by your server, so to test "Siteverify timed out" or "hostname mismatch" you should mock the HTTP call in your backend's unit tests rather than in the E2E suite.
One guard you should add today
The worst Turnstile bug is the quiet one: a production deploy that still carries the always-pass test pair. Everything works and nothing is protected. A few lines in CI catch it:
# fail the deploy if a Cloudflare test key reaches production config
if printenv | grep -qE 'TURNSTILE_(SITEKEY|SECRET_KEY)=[123]x0{20}' ; then
echo "Turnstile test key in production environment" >&2
exit 1
fi
Real Turnstile sitekeys start with 0x, so you can also assert the prefix outright.
When you actually need real keys in an automated run
For most teams the answer is: rarely. Run the full suite on test keys, unit-test the Siteverify handling, and do a manual check of the real configuration on each release.
The case that justifies more is a scheduled smoke test against your deployed site, using the production sitekey, to confirm signup still works end to end. There the widget will treat Playwright as a bot, as Cloudflare says it will, and you have two options: keep a person in the loop, or get a real token from a solving service and inject it the way the widget would. If you go the second way:
- Get the token last, right before submit, because of the 300-second window.
- Never reuse a token across retries.
- If the service tells you which user agent the token was produced with, run the browser context with that user agent.
- Keep it to your own properties and a small number of runs; it's a monitor, not a load test.
If a real token still gets rejected, the causes are usually on the request side rather than the token itself; this checklist of why solved tokens get rejected goes through them. For the test-key side of the same problem, including reCAPTCHA's keys, there's a longer guide to testing reCAPTCHA and Turnstile forms.
Where CaptchaNo fits
Disclosure: I work on CaptchaNo, a captcha solving API, and this article was drafted with AI assistance.
For that last smoke-test case, CaptchaNo solves Cloudflare Turnstile on our own solving infrastructure. One POST with type: "turnstile", the sitekey and the page url (plus optional action) returns the token and the user agent to replay; the request format is documented here, and there is a ready Playwright integration example. Only successful solves are billed, credit doesn't expire, and questions go straight to the engineers who run it at [email protected]. Rates are on the pricing page.
Use it on sites you own or are permitted to automate. For everything else in your suite, Cloudflare's dummy keys are free and the right tool.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.