Tutorial
Cypress and Cloudflare Turnstile: Test Your Own Forms End to End
Why Cypress can't pass a live Cloudflare Turnstile widget, and how to test your forms with Cloudflare's testing sitekeys and secrets, per environment, in CI.
By ZeroCaptcha Engineering5 min readPublished
Cypress cannot pass a live Cloudflare Turnstile widget: the widget runs in a cross-origin iframe
that Cypress cannot automate, and Cloudflare says “Automated testing suites (like Selenium, Cypress,
or Playwright) are detected as bots by Turnstile”. To test your own forms end to end, serve them
with Cloudflare’s testing sitekey 1x00000000000000000000AA and validate with the testing
secret 1x0000000000000000000000000000000AA. The widget then passes on its own, puts the dummy
token XXXX.DUMMY.TOKEN.XXXX in the cf-turnstile-response field, and your server’s siteverify
call accepts it. Your Cypress test waits for that value, then submits.
This tutorial is for forms on sites you own. It shows how to pick keys per environment, the Cypress specs, the failure cases worth testing, and the check that keeps testing keys out of production.
Why the widget can’t be clicked from Cypress
Two documented limits meet here:
- Cross-origin iframes. Cypress’s own guide says: “If your site embeds an
<iframe>that is a cross-origin frame, Cypress won’t be able to automate or communicate with this<iframe>.” The Cloudflare Turnstile widget is an iframe served fromchallenges.cloudflare.com. - Bot detection. Cloudflare’s testing page lists what happens when a real widget meets a test runner: “Tests to fail when Turnstile blocks automated browsers”, “Unpredictable test results due to challenge variations”, “Interference with form submission testing” and “Difficulty testing complete user flows”.
Setting chromeWebSecurity: false lets Cypress reach cross-origin iframes in Chrome-based browsers,
but it does not make a browser under test look like a person: the second limit still applies. The
testing keys are the way through.
Cloudflare’s testing keys
On the page (the sitekey):
| Sitekey | Behaviour | Widget |
|---|---|---|
1x00000000000000000000AA |
Always passes | Visible |
2x00000000000000000000AB |
Always fails | Visible |
1x00000000000000000000BB |
Always passes | Invisible |
2x00000000000000000000BB |
Always fails | Invisible |
3x00000000000000000000FF |
Forces an interactive challenge | Visible |
On the server (the secret key used with siteverify):
| Secret key | Behaviour |
|---|---|
1x0000000000000000000000000000000AA |
Always passes validation |
2x0000000000000000000000000000000AA |
Always fails validation |
3x0000000000000000000000000000000AA |
Returns a “token already spent” error |
Two rules come with them. “Production secret keys will reject the dummy token. You must also use a
dummy secret key for testing purposes.” And the testing keys work on localhost, while Cloudflare
recommends “that sitekeys used in production do not allow local domains”. Cloudflare’s testing
keys cost nothing to use, and nothing is sent to a solving service.
Pick the keys by environment
The simplest setup is a separate environment: your CI or staging deployment reads its sitekey and secret from its own settings, set to the testing keys, and production reads the real ones.
// server/turnstile-keys.mjs: one place that decides which keys this deployment uses.export const turnstileKeys = { sitekey: process.env.TURNSTILE_SITE_KEY, secret: process.env.TURNSTILE_SECRET_KEY,};
if (!turnstileKeys.sitekey || !turnstileKeys.secret) { throw new Error("TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY must be set");}When one deployment has to serve both real visitors and tests, Cloudflare’s tutorial on excluding
Turnstile from end-to-end tests detects test requests on the server (by IP address or by a header
only the tests know) and hands them the testing keys. Its Cypress side adds the header with
cy.intercept. Scope it to your own origin, so the secret header never goes to third parties:
beforeEach(() => { cy.intercept({ url: `${Cypress.config("baseUrl")}/**` }, (req) => { req.headers["x-test-environment"] = Cypress.env("TEST_ENVIRONMENT_SECRET"); });});Treat that header’s value as a secret: anyone who has it gets the always-pass keys.
The Cypress test
With the always-pass sitekey, the widget produces its dummy token by itself after the page loads. The test waits for it in the hidden field, then submits:
describe("contact form behind Cloudflare Turnstile", () => { it("submits once the widget has produced its token", () => { cy.visit("/contact"); cy.get("input[name=email]").type("qa@example.com"); cy.get("textarea[name=message]").type("Hello from CI");
// The always-pass testing sitekey fills the hidden field with Cloudflare's dummy token. cy.get('input[name="cf-turnstile-response"]', { timeout: 20000 }).should( "have.value", "XXXX.DUMMY.TOKEN.XXXX", );
cy.get("form").submit(); cy.contains("Thanks, we got your message").should("be.visible"); });});Cypress retries the should until the value appears or 20 seconds pass, so no fixed wait is
needed. If your form renames the field with data-response-field-name, or sends the token from
the widget’s callback in a JSON body, assert on what your page does instead: for example, wait on
a cy.intercept alias for the form’s request and check its body.
The failure cases worth testing
Each row is one CI job, or one environment variable switched between runs:
| Case | Sitekey | Secret | What your app should do |
|---|---|---|---|
| Happy path | 1x00000000000000000000AA |
1x0000000000000000000000000000000AA |
Accept the form |
| Invisible widget | 1x00000000000000000000BB |
1x0000000000000000000000000000000AA |
Accept the form with no visible widget |
| Widget fails | 2x00000000000000000000AB |
1x0000000000000000000000000000000AA |
Show your error message; run your error callback or retry path |
| Server rejects | 1x00000000000000000000AA |
2x0000000000000000000000000000000AA |
Refuse the form, keep the user’s input |
| Token spent | 1x00000000000000000000AA |
3x0000000000000000000000000000000AA |
Treat it as timeout-or-duplicate: ask for a fresh token |
The server-side cases matter most, because a widget can be skipped by anyone who posts to your form directly. Siteverify errors lists every code your server may get back, and retry and refresh settings covers what the widget does on its own after a failure.
Keep testing keys out of production
Cloudflare’s tutorial asks you to “Validate credentials in CI/CD pipelines to prevent test credentials in production”. A few lines in the deploy job are enough:
// scripts/check-turnstile-keys.mjs: run in the production deploy job.const TESTING = /^[123]x0{20,}[A-F]{2}$/;for (const name of ["TURNSTILE_SITE_KEY", "TURNSTILE_SECRET_KEY"]) { const value = process.env[name] ?? ""; if (value === "" || TESTING.test(value)) { console.error(`${name} is missing or is a Cloudflare testing key`); process.exit(1); }}console.log("Cloudflare Turnstile keys look like production keys");When a real token is what you need
Testing keys prove your code, not Cloudflare’s live widget. To check a production form from outside, as a monitor would, you need a real token for the real sitekey. A solving API gives one: see the Cloudflare Turnstile solver. ZeroCaptcha has no test mode or test keys: every task is real and charged when it succeeds, so keep CI on Cloudflare’s testing keys, which cost nothing, and use real tokens only where a real check is the point.
Sources
- Cloudflare Turnstile: test your implementation (checked 1 October 2026).
- Cloudflare Turnstile: exclude Turnstile from E2E tests (checked 1 October 2026).
- Cloudflare Turnstile: widget configurations,
for
response-field-name(checked 1 October 2026). - Cypress: cross origin testing,
for cross-origin iframes and
chromeWebSecurity(checked 1 October 2026).
The team that builds and runs the ZeroCaptcha API. Articles are drafted with AI tools, then checked against the API's code and the primary sources each one cites.