# 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.

- Source: https://zerocaptcha.io/blog/cypress-cloudflare-turnstile
- Published: 2026-10-01
- Author: ZeroCaptcha Engineering

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 from `challenges.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.

```js
// 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:

```js
// cypress/support/e2e.js
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:

```js
// cypress/e2e/contact.cy.js
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](https://zerocaptcha.io/blog/cloudflare-turnstile-siteverify-errors) lists every code
your server may get back, and [retry and refresh settings](https://zerocaptcha.io/blog/cloudflare-turnstile-retry-and-refresh)
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:

```js
// 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](https://zerocaptcha.io/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](https://developers.cloudflare.com/turnstile/troubleshooting/testing/)
  (checked 1 October 2026).
- [Cloudflare Turnstile: exclude Turnstile from E2E tests](https://developers.cloudflare.com/turnstile/tutorials/excluding-turnstile-from-e2e-tests/)
  (checked 1 October 2026).
- [Cloudflare Turnstile: widget configurations](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/),
  for `response-field-name` (checked 1 October 2026).
- [Cypress: cross origin testing](https://docs.cypress.io/app/guides/cross-origin-testing),
  for cross-origin iframes and `chromeWebSecurity` (checked 1 October 2026).

## Questions

### Can Cypress click the Cloudflare Turnstile checkbox?

No. The widget runs in an iframe from challenges.cloudflare.com, and Cypress cannot automate a cross-origin iframe. Cloudflare also says automated testing suites such as Cypress are detected as bots by Turnstile.

### How do I test a Cloudflare Turnstile form in Cypress?

Serve the page with Cloudflare's always-pass testing sitekey 1x00000000000000000000AA and validate with the testing secret 1x0000000000000000000000000000000AA. The widget then fills cf-turnstile-response with the dummy token XXXX.DUMMY.TOKEN.XXXX, which your test waits for before it submits.

### Can I use Cloudflare's testing keys in production?

No. Production secret keys reject the dummy token, and a production page with a testing sitekey protects nothing. Keep the testing keys to development and CI, and check in your pipeline that production never gets them.
