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:
- Browser sitekey:
1x00000000000000000000AA - Server secret:
1x0000000000000000000000000000000AA
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:
success: boolean, the validation resulterror-codes: array of strings, empty on successhostname: domain where the widget renderedaction: custom label set on the client widgetchallenge_ts: ISO 8601 timestamp of the challenge
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.
| Scenario | Test setup | Acceptance check |
|---|---|---|
| Happy path | Passing dummy pair above | Server validates before completing one operation; confirm the persisted result. |
| Missing token | Submit directly without the response field | Server rejects even if browser-side submit controls are bypassed. |
| Rejected token | Failing validation secret from the testing reference | No protected operation; show a recoverable error. |
| Expired or duplicate | Already-spent dummy secret or controlled timeout-or-duplicate response | Request a fresh token; don't retry the protected operation blindly. This checks error handling, not Cloudflare's clock. |
| Wrong hostname or action | Injected validation response with success: true but mismatched expected values | Reject before changing application data. |
| Validation unavailable | Injected timeout or malformed response from the validation dependency | Fail closed, preserve user input where safe, and offer a retry. |
| Test keys in production config | CI startup check using a documented test key | Configuration 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.