How-To

Test Turnstile with Playwright on a Site You Control

Use Cloudflare's paired test keys to validate Turnstile in Playwright. Covers dummy tokens, siteverify, token expiry, and production isolation.

Use Cloudflare's test keys

For a successful local test, configure this public dummy pair:

The sitekey renders a passing widget; the secret accepts its dummy token. These are published test values, not production credentials. Use Cloudflare's testing reference for failure and already-spent variants rather than maintaining a copied key catalog.

Production secrets reject dummy tokens, and test secrets reject real tokens. That protects the token contract, not your deployment: test keys work on any domain, so accidentally deploying the passing pair can weaken a production form. Our recommendation is to make production startup fail when either configured key is a documented test key, and test that guard in CI.

Validate tokens server-side

Your server sends the widget's token to POST https://challenges.cloudflare.com/turnstile/v0/siteverify. It accepts both application/x-www-form-urlencoded and application/json request bodies, and always returns JSON.

Two fields required: secret (your secret key) and response (the token from the widget). Two optional: remoteip (visitor IP) and idempotency_key (a UUID for safe validation retries). Keep that key stable when retrying the same validation request; it doesn't make the protected business operation idempotent.

The response JSON includes:

Production tokens expire after 300 seconds (5 minutes) and allow one successful validation. Expired or already-redeemed tokens return timeout-or-duplicate. A dummy secret that returns that error can test your application's handling; it doesn't prove real token expiry or replay protection.

Source: Cloudflare's server-side validation reference.

Check hostname, action, and production isolation

hostname tells you where the widget ran. In production, compare it against your expected domain to catch tokens replayed from another origin.

action carries a string you set on the client widget. If you protect a login form and a checkout form with the same Turnstile secret, give them different action values and validate server-side. Don't rely on the boolean success alone.

Use the dummy pair only in your isolated test configuration; no separately registered widget is needed for those public keys. Keep production credentials out of CI. When testing hostname and action rejection, inject controlled validation responses at your server's validation boundary and assert that mismatches prevent the operation. Don't weaken production checks to accommodate dummy responses.

Assert the business operation, not the widget

Checking whether the Turnstile widget appeared on the page proves nothing about your validation logic. The widget can render fine while the server skips validation entirely. Or the server validates correctly but your application still fails to complete the protected action for an unrelated reason.

Test the outcome. If your form creates an account, assert the account exists after submission. If it posts a comment, assert the comment shows up.

This example assumes a disposable local dev server configured with the passing pair above and a fresh test account. It hasn't been tested against a specific application. The dashboard checks are UI assertions; add an application-specific check that the account was persisted:

const { test, expect } = require('@playwright/test');

test('signup with Turnstile creates account', async ({ page }) => {
  await page.goto('http://localhost:3000/signup');

  await page.fill('#email', 'test@example.com');
  await page.fill('#password', 'testpass123');

  await page.waitForFunction(() => {
    const tokenInput = document.querySelector('[name="cf-turnstile-response"]');
    return tokenInput && tokenInput.value.length > 0;
  }, undefined, { timeout: 10_000 });

  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('http://localhost:3000/dashboard');
  await expect(page.locator('.welcome')).toContainText('test@example.com');
});

cf-turnstile-response is Turnstile's default hidden input name. If your integration uses the JavaScript API callback instead, adjust the wait condition. Playwright's waitForFunction takes the function argument second and options third. The URL assertion uses an absolute URL so it doesn't depend on a configured baseURL.

Acceptance matrix

Use these as proposed acceptance checks, not reported test results. In every rejection case, assert that the account or other protected record was not created. On narrow screens, scroll the table horizontally.

ScenarioTest setupAcceptance check
Happy pathPassing dummy pair aboveServer validates before completing one operation; confirm the persisted result.
Missing tokenSubmit directly without the response fieldServer rejects even if browser-side submit controls are bypassed.
Rejected tokenFailing validation secret from the testing referenceNo protected operation; show a recoverable error.
Expired or duplicateAlready-spent dummy secret or controlled timeout-or-duplicate responseRequest a fresh token; don't retry the protected operation blindly. This checks error handling, not Cloudflare's clock.
Wrong hostname or actionInjected validation response with success: true but mismatched expected valuesReject before changing application data.
Validation unavailableInjected timeout or malformed response from the validation dependencyFail closed, preserve user input where safe, and offer a retry.
Test keys in production configCI startup check using a documented test keyConfiguration guard refuses to start; no real production deployment needed.

What Turnstile tokens are not

Playwright's storageState captures cookies and localStorage. IndexedDB capture is opt-in with indexedDB: true, available since Playwright 1.51. Loading that snapshot can reuse application authentication, but it doesn't renew a Turnstile form token or make a redeemed token reusable. Each separately protected operation needs a valid, unused token.

A cf_clearance cookie is separate. With Turnstile pre-clearance enabled, the widget can issue that cookie in addition to the form token. A cookie snapshot can include it; whether Cloudflare accepts it depends on its validity and the site's clearance configuration. Its lifetime follows the zone's Challenge Passage setting, not the form token's five-minute limit. Pre-clearance doesn't remove the server's obligation to call Siteverify for the form token.

Separately, Cloudflare Access service tokens authenticate automated clients to an Access application whose policy allows them, using CF-Access-Client-Id and CF-Access-Client-Secret headers (service token docs). Don't treat Access authentication as permission to skip Turnstile or other security checks. If your staging site uses both, configure and test each layer separately.

If your staging environment uses broader Cloudflare challenge pages, the cf-mitigated: challenge response header identifies challenged requests (challenge detection reference). For background on Cloudflare's detection signals, see our Cloudflare Bot Management Signals guide.

Need help with integration tests?

Describe your protected workflow and we'll scope the test surface and configuration. The starter kit is a browser-automation template using Camoufox. It doesn't include Turnstile test configuration or bypass tools.