
Cloudflare Turnstile
- 147 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
Helps with ai & agent building tasks during AI-assisted development.
About
cloudflare-turnstile is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- cloudflare-turnstile
- AI & Agent Building
- AI-coding skill
Cloudflare Turnstile by the numbers
- 147 all-time installs (skills.sh)
- +12 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #3,383 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill cloudflare-turnstileAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 147 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Cloudflare Turnstile
Status: Production Ready ✅ | Last Verified: 2025-11-26
Dependencies: None (optional: @marsidev/react-turnstile for React)
Contents: Quick Start • Critical Rules • Top 12 Errors • Common Patterns • When to Load References • Troubleshooting
---
Quick Start (10 Minutes)
1. Create Turnstile Widget
Get your sitekey and secret key from Cloudflare Dashboard.
# Navigate to: https://dash.cloudflare.com/?to=/:account/turnstile
# Create new widget → Copy sitekey (public) and secret key (private)Why this matters:
- Each widget has unique sitekey/secret pair
- Sitekey goes in frontend (public)
- Secret key ONLY in backend (private)
- Use different widgets for dev/staging/production
2. Add Widget to Frontend
Embed the Turnstile widget in your HTML form.
<!DOCTYPE html>
<html>
<head>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<form id="myForm" action="/submit" method="POST">
<input type="email" name="email" required>
<!-- Turnstile widget renders here -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Submit</button>
</form>
</body>
</html>CRITICAL:
- Never proxy or cache
api.js- must load from Cloudflare CDN - Widget auto-creates hidden input
cf-turnstile-responsewith token - Token expires in 5 minutes
- Each token is single-use only
3. Validate Token on Server
ALWAYS validate the token server-side. Client-side verification alone is not secure.
// Cloudflare Workers example
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const formData = await request.formData()
const token = formData.get('cf-turnstile-response')
const ip = request.headers.get('CF-Connecting-IP')
// Validate token with Siteverify API
const verifyFormData = new FormData()
verifyFormData.append('secret', env.TURNSTILE_SECRET_KEY)
verifyFormData.append('response', token)
verifyFormData.append('remoteip', ip)
const result = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: verifyFormData,
}
)
const outcome = await result.json()
if (!outcome.success) {
return new Response('Invalid Turnstile token', { status: 401 })
}
// Token valid - proceed with form processing
return new Response('Success!')
}
}---
The 3-Step Setup Process
Step 1: Create Widget Configuration
1. Log into Cloudflare Dashboard 2. Navigate to Turnstile section 3. Click "Add Site" 4. Configure:
- Widget Mode: Managed (recommended), Non-Interactive, or Invisible
- Domains: Add allowed hostnames (e.g., example.com, localhost for dev)
- Name: Descriptive name (e.g., "Production Login Form")
Key Points:
- Use separate widgets for dev/staging/production
- Restrict domains to only those you control
- Managed mode provides best balance of security and UX
- localhost must be explicitly added for local testing
Step 2: Client-Side Integration
Choose between implicit or explicit rendering:
Implicit Rendering (Recommended for static forms):
<!-- 1. Load script -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<!-- 2. Add widget -->
<div class="cf-turnstile"
data-sitekey="YOUR_SITE_KEY"
data-callback="onSuccess"
data-error-callback="onError"></div>
<script>
function onSuccess(token) {
console.log('Turnstile success:', token)
}
function onError(error) {
console.error('Turnstile error:', error)
}
</script>Explicit Rendering (For SPAs/dynamic UIs):
// 1. Load script with explicit mode
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" defer></script>
// 2. Render programmatically
const widgetId = turnstile.render('#container', {
sitekey: 'YOUR_SITE_KEY',
callback: (token) => {
console.log('Token:', token)
},
'error-callback': (error) => {
console.error('Error:', error)
},
theme: 'auto',
execution: 'render', // or 'execute' for manual trigger
})
// Control lifecycle
turnstile.reset(widgetId) // Reset widget
turnstile.remove(widgetId) // Remove widget
turnstile.execute(widgetId) // Manually trigger challenge
const token = turnstile.getResponse(widgetId) // Get current tokenReact Integration (using @marsidev/react-turnstile):
import { Turnstile } from '@marsidev/react-turnstile'
export function MyForm() {
const [token, setToken] = useState<string>()
return (
<form>
<Turnstile
siteKey={TURNSTILE_SITE_KEY}
onSuccess={setToken}
onError={(error) => console.error(error)}
/>
<button disabled={!token}>Submit</button>
</form>
)
}Step 3: Server-Side Validation
MANDATORY: Always call Siteverify API to validate tokens.
interface TurnstileResponse {
success: boolean
challenge_ts?: string
hostname?: string
error-codes?: string[]
action?: string
cdata?: string
}
async function validateTurnstile(
token: string,
secretKey: string,
options?: {
remoteip?: string
idempotency_key?: string
expectedAction?: string
expectedHostname?: string
}
): Promise<TurnstileResponse> {
const formData = new FormData()
formData.append('secret', secretKey)
formData.append('response', token)
if (options?.remoteip) {
formData.append('remoteip', options.remoteip)
}
if (options?.idempotency_key) {
formData.append('idempotency_key', options.idempotency_key)
}
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: formData,
}
)
const result = await response.json<TurnstileResponse>()
// Additional validation
if (result.success) {
if (options?.expectedAction && result.action !== options.expectedAction) {
return { success: false, 'error-codes': ['action-mismatch'] }
}
if (options?.expectedHostname && result.hostname !== options.expectedHostname) {
return { success: false, 'error-codes': ['hostname-mismatch'] }
}
}
return result
}
// Usage in Cloudflare Worker
const result = await validateTurnstile(
token,
env.TURNSTILE_SECRET_KEY,
{
remoteip: request.headers.get('CF-Connecting-IP'),
expectedHostname: 'example.com',
}
)
if (!result.success) {
return new Response('Turnstile validation failed', { status: 401 })
}---
Critical Rules
Always Do
✅ Call Siteverify API - Server-side validation is mandatory ✅ Use HTTPS - Never validate over HTTP ✅ Protect secret keys - Never expose in frontend code ✅ Handle token expiration - Tokens expire after 5 minutes ✅ Implement error callbacks - Handle failures gracefully ✅ Use dummy keys for testing - Test sitekey: 1x00000000000000000000AA ✅ Set reasonable timeouts - Don't wait indefinitely for validation ✅ Validate action/hostname - Check additional fields when specified ✅ Rotate keys periodically - Use dashboard or API to rotate secrets ✅ Monitor analytics - Track solve rates and failures ✅ Validate token AFTER form submission - Verify tokens after user completes form, not before. Premature validation creates security vulnerabilities where attackers obtain valid tokens then bypass protection
Never Do
❌ Skip server validation - Client-side only = security vulnerability ❌ Proxy api.js script - Must load from Cloudflare CDN ❌ Reuse tokens - Each token is single-use only ❌ Use GET requests - Siteverify only accepts POST ❌ Expose secret key - Keep secrets in backend environment only ❌ Trust client-side validation - Tokens can be forged ❌ Cache api.js - Future updates will break your integration ❌ Use production keys in tests - Use dummy keys instead ❌ Ignore error callbacks - Always handle failures
---
Known Issues Prevention
This skill prevents 12 documented issues:
Issue #1: Missing Server-Side Validation
Error: Zero token validation in Turnstile Analytics dashboard Source: https://developers.cloudflare.com/turnstile/get-started/ Why It Happens: Developers only implement client-side widget, skip Siteverify call Prevention: All templates include mandatory server-side validation with Siteverify API
Issue #2: Token Expiration (5 Minutes)
Error: success: false for valid tokens submitted after delay Source: https://developers.cloudflare.com/turnstile/get-started/server-side-validation Why It Happens: Tokens expire 300 seconds after generation Prevention: Templates document TTL and implement token refresh on expiration
Issue #3: Secret Key Exposed in Frontend
Error: Security bypass - attackers can validate their own tokens Source: https://developers.cloudflare.com/turnstile/get-started/server-side-validation Why It Happens: Secret key hardcoded in JavaScript or visible in source Prevention: All templates show backend-only validation with environment variables
Issue #4: GET Request to Siteverify
Error: API returns 405 Method Not Allowed Source: https://developers.cloudflare.com/turnstile/migration/recaptcha Why It Happens: reCAPTCHA supports GET, Turnstile requires POST Prevention: Templates use POST with FormData or JSON body
Issue #5: Content Security Policy Blocking
Error: Error 200500 - "Loading error: The iframe could not be loaded" Source: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes Why It Happens: CSP blocks challenges.cloudflare.com iframe Prevention: Skill includes CSP configuration reference and check-csp.sh script
Issue #6: Widget Crash (Error 300030)
Error: Generic client execution error for legitimate users Source: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903 Why It Happens: Unknown - appears to be Cloudflare-side issue (2025) Prevention: Templates implement error callbacks, retry logic, and fallback handling
Issue #7: Configuration Error (Error 600010)
Error: Widget fails with "configuration error" Source: https://community.cloudflare.com/t/repeated-cloudflare-turnstile-error-600010/644578 Why It Happens: Missing or deleted hostname in widget configuration Prevention: Templates document hostname allowlist requirement and verification steps
Issue #8: Safari 18 / macOS 15 "Hide IP" Issue
Error: Error 300010 when Safari's "Hide IP address" is enabled Source: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903 Why It Happens: Privacy settings interfere with challenge signals Prevention: Error handling reference documents Safari workaround (disable Hide IP)
Issue #9: Brave Browser Confetti Animation Failure
Error: Verification fails during success animation Source: https://github.com/brave/brave-browser/issues/45608 (April 2025) Why It Happens: Brave shields block animation scripts Prevention: Templates handle success before animation completes
Issue #10: Next.js + Jest Incompatibility
Error: @marsidev/react-turnstile breaks Jest tests Source: https://github.com/marsidev/react-turnstile/issues/112 (Oct 2025) Why It Happens: Module resolution issues with Jest Prevention: Testing guide includes Jest mocking patterns and dummy sitekey usage
Issue #11: localhost Not in Allowlist
Error: Error 110200 - "Unknown domain: Domain not allowed" Source: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes Why It Happens: Production widget used in development without localhost in allowlist Prevention: Templates use dummy test keys for dev, document localhost allowlist requirement
Issue #12: Token Reuse Attempt
Error: success: false with "token already spent" error Source: https://developers.cloudflare.com/turnstile/troubleshooting/testing Why It Happens: Each token can only be validated once Prevention: Templates document single-use constraint and token refresh patterns
---
Configuration
Wrangler (Workers): Load templates/wrangler-turnstile-config.jsonc for complete configuration. Key settings: vars for public sitekey (safe to commit), secrets for private secret key (use wrangler secret put TURNSTILE_SECRET_KEY).
CSP Directives (if using Content Security Policy):
<meta http-equiv="Content-Security-Policy" content="
script-src 'self' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
connect-src 'self' https://challenges.cloudflare.com;">---
Common Patterns
Hono + Cloudflare Workers: Server-side validation in Workers API routes with Hono framework. Load references/common-patterns.md #pattern-1 when building Workers endpoints requiring bot protection.
React + Next.js: Client-side forms with @marsidev/react-turnstile integration. Load references/common-patterns.md #pattern-2 when integrating Turnstile with React/Next.js applications.
E2E Testing: Automated testing with dummy keys (Playwright, Cypress, Jest). Load references/common-patterns.md #pattern-3 when writing E2E tests or setting up CI/CD pipelines.
Widget Lifecycle: Programmatic widget control for SPAs (render, reset, remove, getToken). Load references/common-patterns.md #pattern-4 when building SPAs requiring explicit widget management.
---
When to Load References
`references/widget-configs.md`: Configuring widget appearance, themes, execution modes, size, language, or retry behavior.
`references/error-codes.md`: Debugging error codes 100, 200, 300, 400, 600* or troubleshooting client-side failures (CSP, domain errors, widget crashes).
`references/testing-guide.md`: Setting up E2E tests (Playwright, Cypress), local development with dummy keys, or CI/CD pipeline integration.
`references/react-integration.md`: Integrating with React, Next.js, or troubleshooting @marsidev/react-turnstile issues (Jest mocking, SSR, hooks).
`references/common-patterns.md`: Building Hono Workers routes, React forms, E2E tests, or widget lifecycle management (explicit rendering).
`references/advanced-topics.md`: Implementing pre-clearance for SPAs, custom actions/cdata, retry strategies, or multi-widget pages.
`references/setup-checklist.md`: Preparing for deployment, verifying complete setup, or ensuring production readiness (14-point checklist).
`references/migration-guide.md`: Migrating from reCAPTCHA (v2) or hCaptcha to Turnstile, including compat mode, API differences, and POST-only Siteverify requirement.
`references/browser-support.md`: Browser compatibility matrix, Safari 18 "Hide IP" workaround, Brave shields issues, and browser-specific fallbacks.
`references/mobile-implementation.md`: WebView integration for iOS, Android, React Native, and Flutter, including User Agent consistency and storage persistence requirements.
`templates/`: wrangler-turnstile-config.jsonc (Workers env), turnstile-widget-implicit.html (static forms), turnstile-widget-explicit.ts (SPA rendering), turnstile-server-validation.ts (Siteverify API), turnstile-react-component.tsx (React integration), turnstile-hono-route.ts (Hono validation), turnstile-test-config.ts (testing setup)
`scripts/check-csp.sh`: Verify Content Security Policy allows Turnstile (usage: ./scripts/check-csp.sh https://example.com)
---
Dependencies
Required: None (Turnstile loads from Cloudflare CDN)
Optional: @marsidev/react-turnstile@1.3.1 (React), turnstile-types@1.2.3 (TypeScript), vue-turnstile (Vue 3), ngx-turnstile (Angular), svelte-turnstile (Svelte), @nuxtjs/turnstile (Nuxt)
---
Official Documentation
Turnstile: https://developers.cloudflare.com/turnstile/ • Get Started: https://developers.cloudflare.com/turnstile/get-started/ • Error Codes: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/ • Testing: https://developers.cloudflare.com/turnstile/troubleshooting/testing/ • Migration (reCAPTCHA): https://developers.cloudflare.com/turnstile/migration/recaptcha/ • MCP: Use mcp__cloudflare-docs__search_cloudflare_documentation tool
---
Troubleshooting
Problem: Error 110200 - "Unknown domain"
Solution: Add your domain (including localhost for dev) to widget's allowed domains in Cloudflare Dashboard. For local dev, use dummy test sitekey 1x00000000000000000000AA instead.
Problem: Error 300030 - Widget crashes for legitimate users
Solution: Implement error callback with retry logic. This is a known Cloudflare-side issue (2025). Fallback to alternative verification if retries fail.
Problem: Tokens always return success: false
Solution: 1. Check token hasn't expired (5 min TTL) 2. Verify secret key is correct 3. Ensure token hasn't been validated before (single-use) 4. Check hostname matches widget configuration
Problem: CSP blocking iframe (Error 200500)
Solution: Add CSP directives:
<meta http-equiv="Content-Security-Policy" content="
frame-src https://challenges.cloudflare.com;
script-src https://challenges.cloudflare.com;
">Problem: Safari 18 "Hide IP" causing Error 300010
Solution: Document in error message that users should disable Safari's "Hide IP address" setting (Safari → Settings → Privacy → Hide IP address → Off)
Problem: Next.js + Jest tests failing with @marsidev/react-turnstile
Solution: Mock the Turnstile component in Jest setup:
// jest.setup.ts
jest.mock('@marsidev/react-turnstile', () => ({
Turnstile: () => <div data-testid="turnstile-mock" />,
}))---
Token Efficiency: ~65-70% savings vs manual integration
Errors Prevented: 12 documented security/validation issues with complete solutions
Deployment Checklist: Load references/setup-checklist.md for complete 14-point pre-deployment verification
Cloudflare Turnstile - Advanced Topics
Last Updated: 2025-11-26
This reference covers advanced Turnstile features and patterns for specific use cases beyond basic bot protection.
---
Pre-Clearance for SPAs
When to use: Single-page applications requiring persistent challenge validation across route changes
What is Pre-Clearance?
Pre-clearance allows Turnstile to issue a cookie that persists across page navigations, reducing the number of challenges users see in SPAs. Once a user solves a challenge, the pre-clearance cookie validates them for subsequent requests.
Implementation
turnstile.render('#container', {
sitekey: SITE_KEY,
callback: async (token) => {
// Request pre-clearance cookie from server
await fetch('/api/pre-clearance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
},
})Server-Side Pre-Clearance Handler
// Cloudflare Workers example
app.post('/api/pre-clearance', async (c) => {
const { token } = await c.req.json()
// Validate token
const result = await validateTurnstile(token, c.env.TURNSTILE_SECRET_KEY)
if (result.success) {
// Set pre-clearance cookie (httpOnly, secure)
c.header('Set-Cookie', `cf_clearance=validated; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=3600`)
return c.json({ success: true })
}
return c.json({ success: false }, 401)
})Benefits
- Reduced user friction in SPAs
- One challenge per session instead of per page
- Better UX for multi-page workflows
- Cookie-based validation (no token re-verification)
Considerations
- Cookie expiration (typically 1-24 hours)
- Users blocking cookies won't get pre-clearance
- Still validate tokens on sensitive operations
- Pre-clearance supplements, doesn't replace, token validation
---
Custom Actions and cData
When to use: Tracking different challenge types or passing contextual data through verification
Custom Actions
Actions allow you to track different challenge contexts in Turnstile analytics:
turnstile.render('#container', {
sitekey: SITE_KEY,
action: 'login', // Track action in analytics
callback: (token) => {
// Token includes action for server validation
},
})Common action values:
'login'- Login forms'signup'- Registration forms'checkout'- E-commerce checkout'comment'- User-generated content'contact'- Contact forms
Server-Side Action Verification
const result = await validateTurnstile(token, secretKey)
if (result.action !== 'login') {
return new Response('Invalid action', { status: 400 })
}
// Proceed with loginCustom Data (cData)
Pass contextual data through the challenge flow:
turnstile.render('#container', {
sitekey: SITE_KEY,
cdata: JSON.stringify({ userId: '123', orderId: 'abc-456' }), // Max 255 chars
callback: (token) => {
// Token includes cdata for server validation
},
})Server-Side cData Verification
const result = await validateTurnstile(token, secretKey)
const customData = JSON.parse(result.cdata || '{}')
console.log('User ID:', customData.userId)
console.log('Order ID:', customData.orderId)
// Use custom data for additional validation or loggingUse Cases
- Multi-step forms (track current step in cdata)
- A/B testing (pass variant in cdata)
- User context (pass user ID for rate limiting)
- Transaction tracking (pass order ID for audit logs)
Important Constraints
- cdata max length: 255 characters
- Must be JSON-serializable
- Not encrypted (don't pass sensitive data)
- Use for tracking, not security (tokens can be forged client-side)
---
Retry and Error Handling Strategies
When to use: Production applications requiring robust error handling and automatic recovery
Automatic Retry Configuration
class TurnstileWithRetry {
private retryCount = 0
private maxRetries = 3
private widgetId: string | null = null
render(containerId: string) {
this.widgetId = turnstile.render(containerId, {
sitekey: SITE_KEY,
retry: 'auto', // or 'never' for manual control
'retry-interval': 8000, // ms between retries (default: 8000)
'error-callback': (error) => {
this.handleError(error)
},
})
}
private handleError(error: string) {
// Error codes that should NOT retry (permanent failures)
const noRetry = [
'110100', // Invalid sitekey
'110200', // Unknown domain
'110500', // Internal error (Cloudflare-side)
]
if (noRetry.some(code => error.includes(code))) {
this.showFallback()
return
}
// Retry on transient errors with exponential backoff
if (this.retryCount < this.maxRetries) {
this.retryCount++
const backoffMs = 2 ** this.retryCount * 1000 // exponential backoff (1s, 2s, 4s...)
setTimeout(() => {
if (this.widgetId !== null) {
turnstile.reset(this.widgetId)
}
}, backoffMs)
} else {
this.showFallback()
}
}
private showFallback() {
// Show alternative verification method
console.error('Turnstile failed after retries - showing fallback')
// Example: Show email verification, manual review, etc.
}
}Error Code Categories
Permanent Failures (Do NOT Retry):
110100- Invalid sitekey (configuration error)110200- Domain not allowed (configuration error)110500- Internal error (Cloudflare-side issue)
Transient Failures (SAFE to Retry):
300010- Generic client error (network, browser state)300030- Widget crash (Cloudflare-side issue, known bug 2025)600010- Configuration error (may resolve on retry)
Best Practices
1. Exponential Backoff:
const backoffMs = Math.min(2 ** retryCount * 1000, 30000) // Max 30s2. Limited Retries:
const maxRetries = 3 // Prevent infinite retry loops3. Fallback Mechanisms:
- Email verification
- Manual review queue
- Alternative CAPTCHA provider
- Temporary bypass with higher scrutiny
4. User Communication:
if (retryCount > 0) {
showMessage(`Verifying... (attempt ${retryCount}/${maxRetries})`)
}---
Multi-Widget Pages
When to use: Pages with multiple forms requiring independent Turnstile challenges
Implementation
const widgets = {
login: null as string | null,
signup: null as string | null,
contact: null as string | null,
}
// Render multiple widgets on same page
widgets.login = turnstile.render('#login-widget', {
sitekey: SITE_KEY,
action: 'login',
callback: (token) => handleLoginToken(token),
})
widgets.signup = turnstile.render('#signup-widget', {
sitekey: SITE_KEY,
action: 'signup',
callback: (token) => handleSignupToken(token),
})
widgets.contact = turnstile.render('#contact-widget', {
sitekey: SITE_KEY,
action: 'contact',
callback: (token) => handleContactToken(token),
})
// Reset specific widget
function resetLogin() {
if (widgets.login !== null) {
turnstile.reset(widgets.login)
}
}
// Get token from specific widget
function getLoginToken(): string | undefined {
if (widgets.login === null) return undefined
return turnstile.getResponse(widgets.login)
}
// Remove widget on form hide
function hideLoginForm() {
if (widgets.login !== null) {
turnstile.remove(widgets.login)
widgets.login = null
}
}Best Practices
1. Use Different Actions:
// Track which form user is interacting with
action: 'login' | 'signup' | 'contact'2. Independent Callbacks:
// Each widget has its own token handler
callback: (token) => handleSpecificFormToken(token)3. Widget Lifecycle Management:
// Track widget IDs for reset/remove operations
const widgetIds = new Map<string, string>()4. Conditional Rendering:
// Only render widget when form is visible
if (formVisible && !widgetIds.has('login')) {
widgetIds.set('login', turnstile.render('#login-widget', config))
}Common Patterns
Tabbed Forms:
function switchTab(tabName: string) {
// Remove previous widget
if (currentWidget) {
turnstile.remove(currentWidget)
}
// Render new widget for active tab
currentWidget = turnstile.render(`#${tabName}-widget`, {
sitekey: SITE_KEY,
action: tabName,
})
}Modal Dialogs:
function openLoginModal() {
showModal()
widgets.login = turnstile.render('#login-modal-widget', config)
}
function closeLoginModal() {
if (widgets.login) {
turnstile.remove(widgets.login)
widgets.login = null
}
hideModal()
}---
Related Resources
For basic patterns: See references/common-patterns.md For configuration: See references/widget-configs.md For error handling: See references/error-codes.md For testing: See references/testing-guide.md
Official Documentation:
- https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
- https://developers.cloudflare.com/turnstile/reference/widget-configurations/
Turnstile Browser Support Matrix
Comprehensive browser compatibility guide including known issues, workarounds, and fallback strategies.
Last Updated: 2025-12-27 Official Docs: https://developers.cloudflare.com/turnstile/
---
Table of Contents
1. Browser Compatibility Matrix 2. Safari 18 / macOS 15 Issues 3. Brave Browser Issues 4. iOS Safari Considerations 5. Private Browsing Mode 6. Browser Extension Conflicts 7. Fallback Strategies
---
Browser Compatibility Matrix
Desktop Browsers
| Browser | Version | Support Status | Known Issues | Notes |
|---|---|---|---|---|
| Chrome | 90+ | ✅ Full Support | None | Recommended for best performance |
| Firefox | 88+ | ✅ Full Support | None | Full widget support |
| Safari | 14+ | ⚠️ Supported with Issues | Safari 18 "Hide IP" issue | See Safari 18 Issues |
| Edge | 90+ | ✅ Full Support | None | Chromium-based, same as Chrome |
| Brave | 1.40+ | ⚠️ Supported with Issues | Confetti animation failure | See Brave Issues |
| Opera | 76+ | ✅ Full Support | None | Chromium-based |
| Vivaldi | 4.0+ | ✅ Full Support | None | Chromium-based |
Mobile Browsers
| Browser | Platform | Support Status | Known Issues | Notes |
|---|---|---|---|---|
| Chrome Mobile | Android | ✅ Full Support | None | Recommended for Android |
| Safari Mobile | iOS 14+ | ✅ Full Support | None | Full support on iOS |
| Firefox Mobile | Android/iOS | ✅ Full Support | None | Full widget support |
| Edge Mobile | Android/iOS | ✅ Full Support | None | Chromium-based |
| Samsung Internet | Android | ✅ Full Support | None | Chromium-based |
Requirements
All browsers must support:
- ✅ JavaScript execution
- ✅ DOM Storage API (localStorage, sessionStorage)
- ✅ Fetch API or XMLHttpRequest
- ✅ Cookies enabled
- ✅ Third-party cookies allowed for
challenges.cloudflare.com
---
Safari 18 / macOS 15 Issues
Issue: Error 300010 with "Hide IP Address" Enabled
Affected Versions: Safari 18.0+ on macOS 15 (Sequoia) and later
Symptom:
- Turnstile widget displays error 300010
- Challenge fails for legitimate users
- Widget may show "Verification failed" message
Root Cause: Safari 18 introduced "Hide IP address" privacy feature that interferes with Turnstile's challenge signal collection. When enabled, Turnstile cannot properly assess visitor legitimacy.
Official Source:
- https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
- Reported: 2025
- Status: Cloudflare investigating; no ETA for fix
---
Workaround 1: User-Side Fix (Recommended)
Inform users to disable "Hide IP address" in Safari settings:
1. Open Safari 2. Go to Safari → Settings (or Preferences) 3. Click Privacy tab 4. Find "Hide IP address" section 5. Select Off or Trackers and websites 6. Reload the page
Error Message to Display:
Verification failed (Error 300010)
If you're using Safari 18 on macOS 15, please disable "Hide IP address":
1. Safari → Settings → Privacy
2. Set "Hide IP address" to "Off"
3. Reload this page
Alternatively, try using Chrome or Firefox.---
Workaround 2: Retry Logic with Fallback
Implement automatic retry with fallback to contact form:
let retryCount = 0;
const maxRetries = 2;
turnstile.render('#turnstile-widget', {
sitekey: TURNSTILE_SITEKEY,
'error-callback': function(errorCode) {
if (errorCode === '300010' && retryCount < maxRetries) {
retryCount++;
console.log(`Safari Hide IP issue detected. Retry ${retryCount}/${maxRetries}`);
// Wait 2 seconds then reset
setTimeout(() => {
turnstile.reset();
}, 2000);
} else if (errorCode === '300010') {
// Max retries exceeded - show fallback
document.getElementById('safari-warning').style.display = 'block';
document.getElementById('fallback-contact-form').style.display = 'block';
}
}
});---
Detection Script
Detect Safari 18 with Hide IP enabled (proactive warning):
function detectSafari18HideIP() {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const safariVersion = navigator.userAgent.match(/Version\/(\d+)/)?.[1];
if (isSafari && parseInt(safariVersion) >= 18) {
// Safari 18+ detected - show preventive warning
document.getElementById('safari-18-warning').style.display = 'block';
document.getElementById('safari-18-warning').innerHTML = `
<div class="alert alert-warning">
<strong>Safari 18 Users:</strong> If verification fails, please disable
"Hide IP address" in Safari Settings → Privacy, or use Chrome/Firefox.
</div>
`;
}
}
// Run on page load
detectSafari18HideIP();---
Long-Term Solution
Status: Cloudflare is investigating the issue. Monitor these resources:
- Cloudflare Community Forums: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
- Turnstile Changelog: https://developers.cloudflare.com/turnstile/changelog/
Recommendation: Display clear error messaging with workaround instructions until Cloudflare resolves the issue.
---
Brave Browser Issues
Issue: Confetti Animation Failure (Error in Success State)
Affected Versions: Brave 1.40+ with Shields enabled
Symptom:
- Turnstile widget completes verification successfully
- Token is generated correctly
- Confetti success animation fails to display or causes JavaScript error
- Widget may freeze during animation
Root Cause: Brave Shields block third-party scripts and tracking, which can interfere with Turnstile's success animation scripts.
Official Source:
- https://github.com/brave/brave-browser/issues/45608
- Reported: April 2025
- Status: Open issue, workaround available
---
Workaround: Handle Success Before Animation
The Fix: Process the token immediately on success callback, before animation completes.
turnstile.render('#turnstile-widget', {
sitekey: TURNSTILE_SITEKEY,
callback: function(token) {
// ✅ CRITICAL: Handle token IMMEDIATELY
// Don't wait for animation to complete
console.log('Token received:', token);
// Enable form submission immediately
document.getElementById('submit-button').disabled = false;
// Store token for submission
document.getElementById('turnstile-token-input').value = token;
// Animation failure is cosmetic only - token is valid
},
'error-callback': function(error) {
console.error('Turnstile error:', error);
document.getElementById('brave-shields-warning').style.display = 'block';
}
});Key Point: Token is valid even if animation fails. Success callback fires before animation, so process token immediately.
---
User Guidance for Brave Shields
If users report issues, provide instructions to disable Shields:
<div id="brave-shields-warning" style="display:none;">
<div class="alert alert-info">
<strong>Brave Browser Detected:</strong> If verification fails,
try disabling Brave Shields for this site:
<ol>
<li>Click the Brave icon (lion) in address bar</li>
<li>Toggle "Shields" to Off for this site</li>
<li>Reload the page</li>
</ol>
</div>
</div>---
Detection Script
function isBraveBrowser() {
return navigator.brave && typeof navigator.brave.isBrave === 'function';
}
if (isBraveBrowser()) {
console.log('Brave browser detected - using animation-safe pattern');
// Display preventive message
document.getElementById('brave-info').style.display = 'block';
}---
iOS Safari Considerations
Full Support on iOS 14+
iOS Safari has full Turnstile support with no major known issues.
Requirements:
- ✅ iOS 14.0 or later
- ✅ JavaScript enabled (default)
- ✅ Cookies enabled
- ✅ No content blockers blocking
challenges.cloudflare.com
---
Content Blocker Compatibility
Some iOS content blockers may interfere with Turnstile:
Common Content Blockers:
- 1Blocker
- AdGuard
- Wipr
- Ka-Block!
Symptom: Widget fails to load or displays loading spinner indefinitely
Solution: Whitelist challenges.cloudflare.com in content blocker settings
User Instructions:
If Turnstile widget doesn't load on iOS:
1. Open Settings → Safari → Content Blockers
2. Disable content blockers temporarily
3. Reload the page
4. If it works, whitelist this site in your content blocker---
Responsive Design Considerations
iOS Safari requires responsive widget sizing:
<!-- ✅ GOOD: Flexible width on mobile -->
<div class="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-size="flexible"></div>
<!-- ❌ AVOID: Fixed width on mobile (may overflow) -->
<div class="cf-turnstile"
data-sitekey="YOUR_SITEKEY"
data-size="normal"></div>Recommended: Use data-size="flexible" for mobile-friendly layouts.
---
Private Browsing Mode
Behavior in Private/Incognito Mode
Turnstile works in private browsing but with limitations:
| Browser | Private Mode | Turnstile Behavior | Notes |
|---|---|---|---|
| Chrome Incognito | ✅ Supported | May show challenge more frequently | Cookies cleared on session end |
| Firefox Private | ✅ Supported | Normal behavior | Full support |
| Safari Private | ⚠️ Partial | May require manual challenge | Strict cookie policies |
| Edge InPrivate | ✅ Supported | Normal behavior | Chromium-based |
---
Known Issues
Safari Private Browsing:
- Stricter third-party cookie restrictions
- May require interactive challenge even for legitimate users
cf_clearancecookies not persisted between sessions
Chrome Incognito:
- Higher challenge frequency (expected behavior)
- No persistent clearance cookies
- Each session starts fresh
---
Recommendations
1. Don't block private browsing - Turnstile still provides protection 2. Expect higher challenge rates - Normal due to lack of browsing history 3. Document behavior - Inform users that private mode may require more interaction 4. Test thoroughly - Verify widget loads in all private modes
---
Browser Extension Conflicts
Common Conflicting Extensions
| Extension Type | Symptoms | Solution |
|---|---|---|
| Ad Blockers (uBlock Origin, AdBlock Plus) | Widget blocked or hidden | Whitelist challenges.cloudflare.com |
| Privacy Extensions (Privacy Badger, Ghostery) | Challenge fails to load | Allow third-party scripts from Cloudflare |
| Anti-Tracking (Disconnect, DuckDuckGo Privacy) | Token validation fails | Whitelist domain |
| VPN Extensions (NordVPN, ExpressVPN) | Higher challenge frequency | Expected behavior (IP reputation) |
| Script Blockers (NoScript, ScriptSafe) | Widget doesn't render | Allow scripts from challenges.cloudflare.com |
---
Detection & User Guidance
Detect Extension Interference:
function detectExtensionBlock() {
const widgetElement = document.querySelector('.cf-turnstile');
// Check if widget loaded
setTimeout(() => {
const iframe = widgetElement?.querySelector('iframe');
if (!iframe) {
// Widget failed to load - likely blocked
document.getElementById('extension-warning').style.display = 'block';
console.warn('Turnstile widget blocked - possible browser extension');
}
}, 3000);
}
// Run after widget should have loaded
window.addEventListener('load', detectExtensionBlock);User Message:
<div id="extension-warning" style="display:none;">
<div class="alert alert-warning">
<strong>Verification Not Loading?</strong>
<p>A browser extension may be blocking the verification widget.</p>
<p>Try:</p>
<ul>
<li>Disabling ad blockers for this site</li>
<li>Allowing scripts from challenges.cloudflare.com</li>
<li>Using browser's private/incognito mode</li>
</ul>
</div>
</div>---
Fallback Strategies
Strategy 1: Progressive Enhancement
Provide alternative verification methods:
let turnstileLoaded = false;
turnstile.render('#turnstile-widget', {
sitekey: TURNSTILE_SITEKEY,
callback: function(token) {
turnstileLoaded = true;
document.getElementById('alternative-verification').style.display = 'none';
},
'error-callback': function(error) {
// Show alternative after multiple failures
if (retryCount >= 3) {
document.getElementById('alternative-verification').style.display = 'block';
document.getElementById('alternative-verification').innerHTML = `
<p>Having trouble with verification? Please contact support:</p>
<a href="mailto:support@example.com">support@example.com</a>
`;
}
}
});
// Timeout fallback (if widget doesn't load in 10 seconds)
setTimeout(() => {
if (!turnstileLoaded) {
document.getElementById('slow-load-warning').style.display = 'block';
}
}, 10000);---
Strategy 2: Browser-Specific Messages
Tailor messages based on detected browser:
function getBrowserInfo() {
const ua = navigator.userAgent;
if (/Safari/.test(ua) && !/Chrome/.test(ua)) {
return { name: 'Safari', version: ua.match(/Version\/(\d+)/)?.[1] };
} else if (/Chrome/.test(ua)) {
return { name: 'Chrome', version: ua.match(/Chrome\/(\d+)/)?.[1] };
} else if (/Firefox/.test(ua)) {
return { name: 'Firefox', version: ua.match(/Firefox\/(\d+)/)?.[1] };
} else if (navigator.brave) {
return { name: 'Brave', version: 'Unknown' };
}
return { name: 'Unknown', version: 'Unknown' };
}
const browser = getBrowserInfo();
if (browser.name === 'Safari' && parseInt(browser.version) >= 18) {
// Show Safari 18 specific guidance
document.getElementById('safari-18-help').style.display = 'block';
} else if (browser.name === 'Brave') {
// Show Brave specific guidance
document.getElementById('brave-help').style.display = 'block';
}---
Strategy 3: Graceful Degradation
Allow form submission with warning if Turnstile fails:
const MAX_WAIT_TIME = 15000; // 15 seconds
let verificationComplete = false;
turnstile.render('#turnstile-widget', {
sitekey: TURNSTILE_SITEKEY,
callback: function(token) {
verificationComplete = true;
}
});
// Fallback after timeout
setTimeout(() => {
if (!verificationComplete) {
document.getElementById('bypass-warning').style.display = 'block';
document.getElementById('submit-button').disabled = false;
// Add flag for server to handle gracefully
document.getElementById('verification-bypassed').value = 'true';
}
}, MAX_WAIT_TIME);Server-side handling:
if (req.body.verification_bypassed === 'true') {
// Log the bypass for monitoring
console.warn('Form submitted with Turnstile bypass:', {
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date()
});
// Apply additional validation or rate limiting
// Don't automatically reject - may be legitimate browser issue
}---
Testing Across Browsers
Testing Checklist
Desktop:
- [ ] Chrome/Edge (latest)
- [ ] Firefox (latest)
- [ ] Safari (latest)
- [ ] Safari 18+ with "Hide IP" enabled
- [ ] Brave with Shields enabled
- [ ] Opera (Chromium)
Mobile:
- [ ] iOS Safari (latest)
- [ ] Chrome Mobile Android
- [ ] Firefox Mobile
- [ ] Samsung Internet
Special Modes:
- [ ] Chrome Incognito
- [ ] Firefox Private
- [ ] Safari Private
- [ ] With common ad blockers (uBlock Origin, AdBlock Plus)
- [ ] With VPN extensions
---
Monitoring & Analytics
Track Browser-Specific Issues
Use Turnstile Analytics dashboard to monitor:
- Solve rates by browser
- Error rates by browser family
- Challenge frequency patterns
Client-Side Logging
turnstile.render('#turnstile-widget', {
sitekey: TURNSTILE_SITEKEY,
callback: function(token) {
// Log successful verification
logTurnstileEvent('success', {
browser: getBrowserInfo(),
timestamp: Date.now()
});
},
'error-callback': function(errorCode) {
// Log error with browser context
logTurnstileEvent('error', {
errorCode: errorCode,
browser: getBrowserInfo(),
timestamp: Date.now()
});
}
});
function logTurnstileEvent(event, data) {
// Send to analytics service
fetch('/api/log-turnstile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event, ...data })
});
}---
Additional Resources
- Turnstile Troubleshooting: https://developers.cloudflare.com/turnstile/troubleshooting/
- Error Codes Reference: Load
error-codes.mdfor complete error code details - Community Forums: https://community.cloudflare.com/c/security/turnstile/
- Browser Compatibility Testing: https://www.browserstack.com/
---
Best Practice: Test Turnstile integration across multiple browsers during development, and implement fallback strategies for known browser issues.
Recommendation: Display browser-specific help messages proactively for Safari 18 and Brave users to reduce support tickets.
Cloudflare Turnstile - Common Implementation Patterns
Last Updated: 2025-11-26
This reference provides complete code examples for the most common Turnstile integration patterns across different frameworks and use cases.
---
Pattern 1: Hono + Cloudflare Workers
When to use: Server-side validation in Cloudflare Workers with Hono framework
Use cases:
- API routes requiring bot protection
- Form submission endpoints
- Login/authentication endpoints
- Any Workers-based API with user input
Complete Implementation:
import { Hono } from 'hono'
type Bindings = {
TURNSTILE_SECRET_KEY: string
TURNSTILE_SITE_KEY: string
}
const app = new Hono<{ Bindings: Bindings }>()
app.post('/api/login', async (c) => {
const body = await c.req.formData()
const token = body.get('cf-turnstile-response')
if (!token) {
return c.text('Missing Turnstile token', 400)
}
// Validate token
const verifyFormData = new FormData()
verifyFormData.append('secret', c.env.TURNSTILE_SECRET_KEY)
verifyFormData.append('response', token.toString())
verifyFormData.append('remoteip', c.req.header('CF-Connecting-IP') || '')
const verifyResult = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: verifyFormData,
}
)
const outcome = await verifyResult.json<{ success: boolean }>()
if (!outcome.success) {
return c.text('Invalid Turnstile token', 401)
}
// Process login
return c.json({ message: 'Login successful' })
})
export default appKey Points:
- Always validate token server-side (never trust client-side only)
- Use
CF-Connecting-IPheader for remoteip verification - Return appropriate HTTP status codes (400 for missing token, 401 for invalid)
- Secret key must be stored in environment bindings, never hardcoded
Template File: See templates/turnstile-hono-route.ts for complete Hono route handler
---
Pattern 2: React + Next.js App Router
When to use: Client-side forms in Next.js with React hooks and @marsidev/react-turnstile
Use cases:
- Contact forms
- Newsletter signups
- User registration forms
- Any Next.js client component with user input
Complete Implementation:
'use client'
import { Turnstile } from '@marsidev/react-turnstile'
import { useState } from 'react'
export function ContactForm() {
const [token, setToken] = useState<string>()
const [error, setError] = useState<string>()
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!token) {
setError('Please complete the challenge')
return
}
const formData = new FormData(e.currentTarget)
formData.append('cf-turnstile-response', token)
const response = await fetch('/api/contact', {
method: 'POST',
body: formData,
})
if (!response.ok) {
setError('Submission failed')
return
}
// Success
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
onError={() => setError('Challenge failed')}
onExpire={() => setToken(undefined)}
/>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={!token}>
Submit
</button>
</form>
)
}Key Points:
- Use
'use client'directive for Next.js client components - Disable submit button until token is available
- Handle all three callbacks: onSuccess, onError, onExpire
- Clear token on expiration (tokens expire after 5 minutes)
- Use environment variable for sitekey (NEXT_PUBLIC_ prefix makes it accessible client-side)
- Server-side validation must still be implemented (see Pattern 1)
Template File: See templates/turnstile-react-component.tsx for complete React component
---
Pattern 3: E2E Testing with Dummy Keys
When to use: Automated testing with Playwright, Cypress, or Jest
Use cases:
- End-to-end tests
- Integration tests
- Local development testing
- CI/CD pipelines
Complete Implementation:
// test/helpers/turnstile.ts
export const TEST_TURNSTILE = {
sitekey: {
alwaysPass: 'TEST_SITEKEY_PASS', // Test token
alwaysBlock: 'TEST_SITEKEY_BLOCK', // Test token
invisible: 'TEST_SITEKEY_INVISIBLE', // Test token
interactive: 'TEST_SITEKEY_INTERACTIVE', // Test token
},
secretKey: {
alwaysPass: 'TEST_SECRET_PASS', // Test secret
alwaysFail: 'TEST_SECRET_FAIL', // Test secret
tokenSpent: 'TEST_SECRET_SPENT', // Test secret
},
dummyToken: 'DUMMY_TOKEN_PLACEHOLDER', // Dummy token for testing
}
// Playwright test example
test('form submission with Turnstile', async ({ page }) => {
// Set test environment
await page.goto('/contact?test=true')
// Widget uses test sitekey in test mode
await page.fill('input[name="email"]', 'test@example.com')
// Turnstile auto-solves with dummy token
await page.click('button[type="submit"]')
await expect(page.locator('.success')).toBeVisible()
})Key Points:
- Always Pass sitekey (1x00000000000000000000AA): Auto-solves every challenge
- Always Block sitekey (2x00000000000000000000AB): Fails every challenge
- Invisible sitekey (1x00000000000000000000BB): No visual challenge
- Interactive sitekey (3x00000000000000000000FF): Always shows interactive challenge
- Never use production keys in tests (rate limits + analytics pollution)
- Test both success and failure scenarios
- Dummy keys work in all environments (dev, staging, CI)
Template File: See templates/turnstile-test-config.ts for complete testing configuration
---
Pattern 4: Widget Lifecycle Management
When to use: SPAs requiring programmatic widget control (Vue, React without @marsidev, vanilla JS)
Use cases:
- Single-page applications
- Dynamic forms (show/hide)
- Multi-step wizards
- Any scenario requiring explicit widget control
Complete Implementation:
class TurnstileManager {
private widgetId: string | null = null
private sitekey: string
constructor(sitekey: string) {
this.sitekey = sitekey
}
render(containerId: string, callbacks: {
onSuccess: (token: string) => void
onError: (error: string) => void
}) {
if (this.widgetId !== null) {
this.reset() // Reset if already rendered
}
this.widgetId = turnstile.render(containerId, {
sitekey: this.sitekey,
callback: callbacks.onSuccess,
'error-callback': callbacks.onError,
'expired-callback': () => this.reset(),
})
return this.widgetId
}
reset() {
if (this.widgetId !== null) {
turnstile.reset(this.widgetId)
}
}
remove() {
if (this.widgetId !== null) {
turnstile.remove(this.widgetId)
this.widgetId = null
}
}
getToken(): string | undefined {
if (this.widgetId === null) return undefined
return turnstile.getResponse(this.widgetId)
}
}
// Usage
const manager = new TurnstileManager(SITE_KEY)
manager.render('#container', {
onSuccess: (token) => console.log('Token:', token),
onError: (error) => console.error('Error:', error),
})Key Points:
- Use
turnstile.render()for explicit rendering (not implicit div attribute) - Store widgetId for lifecycle control (reset, remove, getToken)
- Call
reset()to clear widget and request new challenge - Call
remove()to completely destroy widget (e.g., component unmount) - Use
getToken()to retrieve current token without callback - Expired callback should automatically reset widget
Template File: See templates/turnstile-widget-explicit.ts for complete explicit rendering API
---
Additional Resources
For more patterns:
references/react-integration.md- React-specific patterns and hooksreferences/testing-guide.md- Complete testing strategiesreferences/widget-configs.md- All widget configuration optionsreferences/advanced-topics.md- Pre-clearance, retry logic, multi-widget
Official Documentation:
- https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
- https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
Turnstile Error Codes Reference
Complete error code reference with troubleshooting
Official Docs: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
---
Error Code Families
Error codes use the format XXXYYY where:
XXX= Error family (indicates general category)YYY= Specific error (internal use, often marked***in docs)
Note: When *** appears, the last 3 digits can be ignored.
---
100*** - Initialization Problems
Error: Problem initializing Turnstile before challenge could start
Retry: No
Cause: Usually caused by:
- Old instance of solved challenge still present
- Page state corruption
- Cache issues
Solution: 1. Reload the page 2. Clear browser cache 3. Reset Turnstile widget programmatically 4. On continuous failures → likely automated device
---
102*, 103, 104, 106* - Invalid Parameters
Error: Visitor sent invalid parameter as part of challenge
Retry: Yes
Cause:
- Malformed request data
- Corrupted challenge parameters
- Browser/extension interference
Solution: 1. Retry the challenge automatically 2. On continuous failures → likely bot 3. Implement error-callback with retry logic 4. Verify visitor authenticity by other means
---
105*** - API Compatibility
Error: Turnstile invoked in deprecated or invalid way
Retry: No
Cause:
- Using outdated API methods
- Invalid widget configuration
- Script version mismatch
Solution: 1. Check official Turnstile documentation 2. Refresh page to get latest script version 3. Review widget initialization code 4. Ensure api.js loads from Cloudflare CDN
---
110100, 110110 - Invalid Sitekey
Error: Turnstile invoked with invalid or inactive sitekey
Retry: No
Cause:
- Sitekey doesn't exist
- Sitekey was deleted
- Typo in sitekey
- Using wrong sitekey for environment
Solution: 1. Verify sitekey in Cloudflare Dashboard 2. Check sitekey is still active 3. Ensure no typos in configuration 4. Use correct sitekey for environment (dev/prod)
Example:
// ❌ Wrong
const SITE_KEY = '1x00000000000000000000AA' // Test key in production
// ✅ Correct
const SITE_KEY = process.env.TURNSTILE_SITE_KEY---
110200 - Unknown Domain
Error: Domain not allowed for this widget
Retry: No
Cause:
- Current hostname not in widget's allowed domains list
- Using production widget on localhost
- Subdomain not added to allowlist
Solution: 1. Add domain to allowed list in Cloudflare Dashboard 2. For localhost: add localhost or use test sitekey 1x00000000000000000000AA 3. Check subdomain matches exactly (www.example.com ≠ example.com)
Example Allowed Domains:
example.com
www.example.com
localhost # For development
127.0.0.1 # For development---
110420 - Invalid Action
Error: Unsupported or incorrectly formatted action submitted
Retry: No
Cause:
- Action contains invalid characters
- Action exceeds 32 character limit
- Non-alphanumeric characters (except
-and_)
Solution: 1. Use only a-z, A-Z, 0-9, -, _ 2. Keep action ≤ 32 characters 3. Example valid actions: login, signup, contact-form
Reference: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations
---
110430 - Invalid cData
Error: Custom data (cData) format invalid
Retry: No
Cause:
- cData contains invalid characters
- cData exceeds 255 character limit
Solution: 1. Keep cData ≤ 255 characters 2. Use JSON.stringify() for objects 3. Validate data before passing to Turnstile
Example:
// ❌ Wrong - too long
const cdata = JSON.stringify({ /* 300+ chars */ })
// ✅ Correct
const cdata = JSON.stringify({ userId: '123', sessionId: 'abc' })---
110500 - Unsupported Browser
Error: Visitor using unsupported browser
Retry: No
Cause:
- Internet Explorer (not supported)
- Very outdated browser version
- Browser without required APIs
Solution: 1. Encourage visitor to upgrade browser 2. Provide alternative verification method 3. Display browser upgrade message
Supported Browsers: https://developers.cloudflare.com/cloudflare-challenges/reference/supported-browsers/
---
110510 - Inconsistent User-Agent
Error: Visitor provided inconsistent user-agent during challenge
Retry: No
Cause:
- Browser extensions spoofing user-agent
- Privacy tools modifying headers
- Browser settings
Solution: 1. Ask visitor to disable user-agent spoofing extensions 2. Disable privacy tools temporarily 3. Try different browser
---
11060* - Challenge Timed Out
Error: Visitor took too long to solve challenge
Retry: Yes
Cause:
- Slow network connection
- System clock set incorrectly
- Visitor distracted/inactive
Solution: 1. Retry the challenge 2. Check system clock is correct 3. Improve network connection
---
11062* - Interactive Challenge Timeout
Error: Visitor didn't interact with checkbox (visible mode only)
Retry: Yes
Cause:
- Challenge became outdated while waiting for interaction
- User abandoned form
- Long delays between rendering and submission
Solution: 1. Reset widget programmatically 2. Re-initialize widget 3. Prompt user to interact
Example:
{
'timeout-callback': () => {
turnstile.reset(widgetId)
alert('Please complete the verification')
}
}---
120*** - Internal Cloudflare Errors
Error: Internal debugging errors (Cloudflare employees only)
Retry: N/A
Solution: Only encountered by Cloudflare Support during debugging.
---
200010 - Invalid Caching
Error: Some portion of Turnstile was accidentally cached
Retry: No
Cause:
- Browser cached Turnstile resources incorrectly
- CDN/proxy caching
api.jsscript
Solution: 1. Clear browser cache 2. Hard refresh (Ctrl+Shift+R or Cmd+Shift+R) 3. Ensure api.js is not proxied or cached
CRITICAL: Never proxy or cache https://challenges.cloudflare.com/turnstile/v0/api.js
---
200100 - Time Problem
Error: Visitor's system clock is incorrect
Retry: No
Cause:
- System time is wrong
- Timezone misconfigured
- Date/time not synchronized
Solution: 1. Set system clock to correct time 2. Enable automatic time synchronization 3. Check timezone settings
---
200500 - Loading Error
Error: iframe under challenges.cloudflare.com could not be loaded
Retry: No
Cause:
- Content Security Policy (CSP) blocking iframe
- Browser security settings blocking 3rd-party iframes
- Network firewall blocking challenges.cloudflare.com
Solution: 1. Add CSP directives:
<meta http-equiv="Content-Security-Policy" content="
script-src 'self' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
connect-src 'self' https://challenges.cloudflare.com;
">2. Reduce browser security preferences 3. Check firewall/network settings
Most Common Cause: CSP blocking. See check-csp.sh script.
---
300*** - Generic Client Execution Error
Error: Unspecified error occurred while visitor solved challenge
Retry: Yes
Cause:
- Browser extension interference
- JavaScript errors on page
- Memory issues
- Network interruption
Solution: 1. Retry automatically 2. On continuous failures → potentially automated visitor 3. Disable browser extensions 4. Try incognito/private mode
Known Issue (2025): Safari 18 + macOS 15 with "Hide IP" enabled causes Error 300010.
Safari Fix: Settings → Privacy → Hide IP address → Off
Source: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
---
300030 - Widget Crash
Error: Widget crashed for legitimate users
Retry: Yes
Cause: Unknown - Cloudflare-side issue (2025)
Solution: 1. Implement robust error handling 2. Retry with exponential backoff 3. Provide fallback verification method
Example:
let retryCount = 0
const maxRetries = 3
turnstile.render('#container', {
sitekey: SITE_KEY,
'error-callback': (error) => {
if (error.includes('300030') && retryCount < maxRetries) {
retryCount++
setTimeout(() => {
turnstile.reset(widgetId)
}, 2000 * retryCount) // Exponential backoff
} else {
showFallbackVerification()
}
}
})Source: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
---
400020 - Invalid Sitekey (Server)
Error: Sitekey is invalid or does not exist
Retry: No
Cause: Same as 110100/110110 but caught server-side
Solution: Verify sitekey exists and is active
---
400030 - Invalid Size
Error: Provided size option is not valid
Retry: No
Cause: Using invalid size parameter
Valid Options: normal, compact, flexible
Solution:
// ❌ Wrong
{ size: 'large' }
// ✅ Correct
{ size: 'compact' }---
400040 - Invalid Theme
Error: Provided theme is not valid
Retry: No
Cause: Using invalid theme parameter
Valid Options: light, dark, auto
Solution:
// ❌ Wrong
{ theme: 'custom' }
// ✅ Correct
{ theme: 'dark' }---
401 - Unauthorized (Expected)
Error: 401 error in browser console during challenge
Retry: N/A
Cause: Turnstile requesting Private Access Token (not supported by all devices/browsers)
Solution: Ignore this error - it's expected behavior
Note: If widget is successfully resolving and generating tokens, no action required.
Source: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
---
600*** - Challenge Execution Failure
Error: Visitor failed to solve Turnstile challenge
Retry: Yes
Cause:
- Suspected bot behavior
- Challenge signals indicate automation
- Failing test sitekey (intentional)
Solution: 1. Retry automatically 2. On continuous failures → likely bot 3. Verify visitor by other means 4. Consider alternative verification
Testing: Test sitekey 2x00000000000000000000AB always fails with this error.
---
600010 - Configuration Error
Error: Widget configuration error
Retry: Depends
Cause:
- Missing hostname in allowlist (most common)
- Hostname was deleted from configuration
- Widget misconfigured
Solution: 1. Check Cloudflare Dashboard → Turnstile → Widget Settings 2. Verify hostname in allowed domains 3. Re-add hostname if missing
Known Issue: Hostnames sometimes disappear from dashboard configuration
Source: https://community.cloudflare.com/t/repeated-cloudflare-turnstile-error-600010/644578
---
Browser-Specific Issues
Brave Browser - Confetti Animation Failure (2025)
Error: Verification fails during success animation
Cause: Brave shields block animation scripts
Solution: Handle success callback before animation completes
Source: https://github.com/brave/brave-browser/issues/45608
---
Troubleshooting Checklist
When encountering errors:
1. Check Error Code Family
- 100*: Initialization → Reload page
- 110*: Configuration → Check sitekey, domain allowlist
- 200*: Client issues → Check cache, CSP, system clock
- 300*: Execution → Retry, check browser compatibility
- 400*: Invalid input → Fix configuration
- 600*: Challenge failure → Check for bot-like behavior
2. Common Fixes
- Clear browser cache
- Disable browser extensions
- Try incognito/private mode
- Check CSP headers
- Verify system clock
- Use test sitekey for development
3. Network/Firewall
- Ensure
challenges.cloudflare.comis accessible - Check for VPN/proxy interference
- Verify no firewall blocking
4. Code Review
- Server-side validation implemented?
- Token expiration handled?
- Error callbacks configured?
- Using latest
api.jsfrom CDN?
---
Last Updated: 2025-10-22 Most Common Errors: 110200 (domain), 200500 (CSP), 300030 (crash), 600010 (config)
Turnstile Migration Guide
Comprehensive guide for migrating from reCAPTCHA or hCaptcha to Cloudflare Turnstile.
Last Updated: 2025-12-27 Official Docs: https://developers.cloudflare.com/turnstile/migration/
---
Table of Contents
1. reCAPTCHA v2 → Turnstile 2. hCaptcha → Turnstile 3. Common Migration Issues 4. Migration Checklist 5. Code Examples
---
reCAPTCHA v2 → Turnstile
Overview
Turnstile provides a compatibility mode for reCAPTCHA v2, enabling drop-in replacement with minimal code changes. Note: Only reCAPTCHA v2 is supported, not v3.
Step 1: Obtain Turnstile Credentials
1. Log into Cloudflare Dashboard 2. Navigate to Turnstile section 3. Create new widget 4. Copy sitekey (public) and secret key (private)
Store secret key securely (environment variables, secrets manager).
---
Step 2: Client-Side Changes
Script Replacement with Compatibility Mode
Replace the reCAPTCHA script with Turnstile's compatibility script:
Before (reCAPTCHA):
<script src="https://www.google.com/recaptcha/api.js" async defer></script>After (Turnstile with compat mode):
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha" async defer></script>Key Parameter: ?compat=recaptcha
This enables:
- Implicit rendering matching reCAPTCHA behavior
- Form input name remains
g-recaptcha-response(no HTML changes needed) - API registration as
grecaptcha(JavaScript code continues to work)
---
Widget Configuration (No Changes Needed)
Your existing reCAPTCHA widget div works as-is with the new sitekey:
<!-- reCAPTCHA widget (update sitekey only) -->
<div class="g-recaptcha"
data-sitekey="YOUR_NEW_TURNSTILE_SITEKEY"
data-callback="onSuccess"
data-error-callback="onError"></div>What stays the same:
- ✅ Class name:
g-recaptcha - ✅ Form input name:
g-recaptcha-response - ✅ Callback functions
- ✅ JavaScript API (
grecaptcha.render(),grecaptcha.execute())
What changes:
- ❌ Sitekey: Use Turnstile sitekey (not reCAPTCHA sitekey)
---
Explicit Rendering (If Used)
If you use grecaptcha.render(), no code changes needed:
// Works with Turnstile in compat mode
grecaptcha.ready(function() {
grecaptcha.render('captcha-container', {
'sitekey': 'YOUR_NEW_TURNSTILE_SITEKEY',
'callback': function(token) {
console.log('Turnstile token:', token);
}
});
});---
Invisible Mode (If Used)
reCAPTCHA v2 invisible mode via grecaptcha.execute() is supported:
// Works with Turnstile in compat mode
grecaptcha.ready(function() {
grecaptcha.execute();
});---
Step 3: Server-Side Changes
Siteverify Endpoint Change
CRITICAL: Turnstile's Siteverify endpoint does NOT support GET requests.
Before (reCAPTCHA):
https://www.google.com/recaptcha/api/siteverifyAfter (Turnstile):
https://challenges.cloudflare.com/turnstile/v0/siteverify⚠️ Breaking Change: reCAPTCHA supports both GET and POST requests. Turnstile only accepts POST with FormData or JSON body.
---
Request Format Change
Before (reCAPTCHA - GET or POST):
// reCAPTCHA allowed GET requests
const url = `https://www.google.com/recaptcha/api/siteverify?secret=${secretKey}&response=${token}`;
const result = await fetch(url, { method: 'GET' });After (Turnstile - POST only):
// Turnstile requires POST with FormData
const formData = new FormData();
formData.append('secret', secretKey);
formData.append('response', token);
formData.append('remoteip', clientIP); // Optional but recommended
const result = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: formData,
}
);Alternative (JSON body):
const result = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: secretKey,
response: token,
remoteip: clientIP,
}),
}
);---
Response Format (Unchanged)
Turnstile returns the same response structure as reCAPTCHA:
{
"success": true,
"challenge_ts": "2025-12-27T12:00:00Z",
"hostname": "example.com",
"error-codes": [],
"action": "login",
"cdata": "custom_data"
}Fields:
success: Boolean validation resultchallenge_ts: ISO 8601 timestamphostname: Domain where challenge occurrederror-codes: Array of error codes (if failed)action: Custom action identifier (if specified)cdata: Custom data payload (if provided)
---
Step 4: Domain Allowlist
reCAPTCHA and Turnstile both require domain allowlists, but configuration differs:
reCAPTCHA:
- Configured in Google reCAPTCHA Admin Console
- Wildcards supported (e.g.,
*.example.com)
Turnstile:
- Configured in Cloudflare Dashboard → Turnstile
- Exact domains only (no wildcards)
localhostmust be explicitly added for local development
Action Required: Add all domains (including localhost for dev) to Turnstile widget configuration.
---
Step 5: Secret Key Security
Best Practice (same for both):
- Store secret keys in environment variables
- Never expose in frontend code
- Use Cloudflare Workers secrets (
wrangler secret put) - Rotate keys periodically
# Cloudflare Workers
wrangler secret put TURNSTILE_SECRET_KEY
# Enter secret key when prompted---
hCaptcha → Turnstile
Overview
Turnstile supports hCaptcha migration with similar API patterns. No compatibility mode available - requires script and code updates.
---
Step 1: Obtain Turnstile Credentials
Same as reCAPTCHA migration (see above).
---
Step 2: Client-Side Changes
Script Replacement
Before (hCaptcha):
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>After (Turnstile):
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>No compatibility mode - must update to Turnstile-native API.
---
Widget HTML Changes
Before (hCaptcha):
<div class="h-captcha"
data-sitekey="YOUR_HCAPTCHA_SITEKEY"
data-callback="onSuccess"></div>After (Turnstile):
<div class="cf-turnstile"
data-sitekey="YOUR_TURNSTILE_SITEKEY"
data-callback="onSuccess"></div>Changes Required:
- ❌ Class:
h-captcha→cf-turnstile - ❌ Sitekey: Use Turnstile sitekey
- ✅ Callbacks: Same names work
---
Form Input Name Change
hCaptcha and Turnstile use different hidden input names:
Before (hCaptcha):
const token = formData.get('h-captcha-response');After (Turnstile):
const token = formData.get('cf-turnstile-response');Update all server-side code that reads the token from form submissions.
---
JavaScript API Changes
Before (hCaptcha):
// Explicit rendering
hcaptcha.render('container', {
sitekey: 'YOUR_HCAPTCHA_SITEKEY',
callback: onSuccess,
});
// Invisible mode
hcaptcha.execute();After (Turnstile):
// Explicit rendering
turnstile.render('container', {
sitekey: 'YOUR_TURNSTILE_SITEKEY',
callback: onSuccess,
});
// Invisible mode (via execute parameter)
turnstile.render('container', {
sitekey: 'YOUR_TURNSTILE_SITEKEY',
execution: 'execute', // Invisible mode
callback: onSuccess,
});Key Difference: Turnstile uses execution: 'execute' parameter instead of separate execute() method.
---
Step 3: Server-Side Changes
Siteverify Endpoint Change
Before (hCaptcha):
https://hcaptcha.com/siteverifyAfter (Turnstile):
https://challenges.cloudflare.com/turnstile/v0/siteverify---
Request Format (POST Required)
Same as reCAPTCHA migration - POST with FormData or JSON:
const formData = new FormData();
formData.append('secret', secretKey);
formData.append('response', token);
formData.append('remoteip', clientIP);
const result = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: formData,
}
);---
Response Format (Similar)
hCaptcha and Turnstile both return success boolean and error codes:
{
"success": true,
"challenge_ts": "2025-12-27T12:00:00Z",
"hostname": "example.com"
}---
Common Migration Issues
Issue 1: GET Request to Siteverify (reCAPTCHA only)
Symptom: 405 Method Not Allowed error from Siteverify API
Cause: reCAPTCHA supports GET requests, Turnstile does not
Solution:
// ❌ WRONG (reCAPTCHA pattern)
const url = `https://challenges.cloudflare.com/turnstile/v0/siteverify?secret=${secret}&response=${token}`;
fetch(url, { method: 'GET' });
// ✅ CORRECT (Turnstile requires POST)
const formData = new FormData();
formData.append('secret', secret);
formData.append('response', token);
fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});---
Issue 2: Missing localhost in Domain Allowlist
Symptom: Error 110200 - "Unknown domain" during local development
Cause: Turnstile widget configured without localhost in allowed domains
Solution: 1. Use dummy test sitekey for development: 1x00000000000000000000AA 2. OR: Add localhost to production widget's domain allowlist in Cloudflare Dashboard
Recommended: Use separate widgets for dev/staging/production with appropriate domain allowlists.
---
Issue 3: Secret Key Exposed in Frontend
Symptom: Security bypass - attackers can validate their own tokens
Cause: Secret key hardcoded in JavaScript or visible in network requests
Solution:
- ✅ Store secret key in backend environment only
- ✅ Use Cloudflare Workers secrets:
wrangler secret put TURNSTILE_SECRET_KEY - ✅ Never include secret in frontend code
- ✅ Call Siteverify API from backend only
---
Issue 4: Form Input Name Mismatch (hCaptcha only)
Symptom: Server receives null or undefined token
Cause: Server still looking for h-captcha-response, but Turnstile sends cf-turnstile-response
Solution:
// ❌ WRONG (hCaptcha pattern)
const token = request.body['h-captcha-response'];
// ✅ CORRECT (Turnstile)
const token = request.body['cf-turnstile-response'];---
Issue 5: Token Expiration (5 Minutes)
Symptom: Valid tokens return success: false after delay
Cause: Tokens expire 300 seconds (5 minutes) after generation
Solution:
- Document TTL in user-facing messaging
- Implement token refresh on expiration
- Validate tokens immediately after form submission
- Use error handling to request new token if expired
// Example: Token refresh on expiration
turnstile.render('#captcha', {
sitekey: 'YOUR_SITEKEY',
'expired-callback': function() {
// Auto-refresh widget on expiration
turnstile.reset();
}
});---
Migration Checklist
Pre-Migration
- [ ] Create Turnstile widget in Cloudflare Dashboard
- [ ] Copy sitekey and secret key
- [ ] Add all production domains to allowlist
- [ ] Add
localhostto allowlist (or use test sitekey for dev) - [ ] Review current reCAPTCHA/hCaptcha implementation
- [ ] Identify all client-side and server-side code locations
Client-Side Migration
reCAPTCHA v2:
- [ ] Update script to use
?compat=recaptchaparameter - [ ] Update sitekey in widget div
- [ ] Test form submissions
- [ ] Verify callbacks still work
- [ ] Test invisible mode (if used)
hCaptcha:
- [ ] Replace hCaptcha script with Turnstile script
- [ ] Update class:
h-captcha→cf-turnstile - [ ] Update sitekey
- [ ] Update JavaScript API:
hcaptcha.render()→turnstile.render() - [ ] Test form submissions
- [ ] Verify callbacks work
Server-Side Migration
- [ ] Update Siteverify endpoint URL
- [ ] Change request method to POST (if using GET)
- [ ] Update request body (FormData or JSON)
- [ ] Update secret key in environment variables
- [ ] Update form input name (
h-captcha-response→cf-turnstile-responsefor hCaptcha) - [ ] Test server-side validation
- [ ] Verify error handling works
Testing
- [ ] Test successful validation flow
- [ ] Test failed validation (use test sitekey
2x00000000000000000000AB) - [ ] Test token expiration handling
- [ ] Test on all allowed domains
- [ ] Test in development (localhost)
- [ ] Test in production
- [ ] Verify CSP compatibility (if using CSP headers)
Post-Migration
- [ ] Monitor Turnstile Analytics dashboard
- [ ] Track solve rates
- [ ] Monitor for validation errors
- [ ] Remove old reCAPTCHA/hCaptcha credentials
- [ ] Update documentation
- [ ] Train support team on new error messages
---
Code Examples
Complete reCAPTCHA → Turnstile Migration
Before (reCAPTCHA)
Client-Side:
<!DOCTYPE html>
<html>
<head>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body>
<form action="/submit" method="POST">
<input type="email" name="email" required>
<div class="g-recaptcha" data-sitekey="6Lc...RECAPTCHA_SITEKEY"></div>
<button type="submit">Submit</button>
</form>
</body>
</html>Server-Side:
// Node.js / Express
app.post('/submit', async (req, res) => {
const token = req.body['g-recaptcha-response'];
// ❌ reCAPTCHA allows GET (but not recommended)
const url = `https://www.google.com/recaptcha/api/siteverify?secret=${SECRET_KEY}&response=${token}`;
const result = await fetch(url);
const outcome = await result.json();
if (!outcome.success) {
return res.status(401).send('Verification failed');
}
res.send('Success!');
});After (Turnstile with compat mode)
Client-Side (minimal changes):
<!DOCTYPE html>
<html>
<head>
<!-- ✅ Add ?compat=recaptcha parameter -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha" async defer></script>
</head>
<body>
<form action="/submit" method="POST">
<input type="email" name="email" required>
<!-- ✅ Only sitekey changes -->
<div class="g-recaptcha" data-sitekey="YOUR_TURNSTILE_SITEKEY"></div>
<button type="submit">Submit</button>
</form>
</body>
</html>Server-Side (endpoint + POST required):
// Node.js / Express
app.post('/submit', async (req, res) => {
const token = req.body['g-recaptcha-response']; // ✅ Same name in compat mode
// ✅ POST with FormData
const formData = new FormData();
formData.append('secret', process.env.TURNSTILE_SECRET_KEY);
formData.append('response', token);
formData.append('remoteip', req.ip);
const result = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: formData,
}
);
const outcome = await result.json();
if (!outcome.success) {
return res.status(401).send('Verification failed');
}
res.send('Success!');
});---
Complete hCaptcha → Turnstile Migration
Before (hCaptcha)
Client-Side:
<!DOCTYPE html>
<html>
<head>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
</head>
<body>
<form action="/submit" method="POST">
<input type="email" name="email" required>
<div class="h-captcha" data-sitekey="YOUR_HCAPTCHA_SITEKEY"></div>
<button type="submit">Submit</button>
</form>
</body>
</html>Server-Side:
app.post('/submit', async (req, res) => {
const token = req.body['h-captcha-response'];
const formData = new FormData();
formData.append('secret', process.env.HCAPTCHA_SECRET_KEY);
formData.append('response', token);
const result = await fetch('https://hcaptcha.com/siteverify', {
method: 'POST',
body: formData,
});
const outcome = await result.json();
if (!outcome.success) {
return res.status(401).send('Verification failed');
}
res.send('Success!');
});After (Turnstile)
Client-Side (class + sitekey change):
<!DOCTYPE html>
<html>
<head>
<!-- ✅ Turnstile script (no compat mode for hCaptcha) -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<form action="/submit" method="POST">
<input type="email" name="email" required>
<!-- ✅ Change class and sitekey -->
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITEKEY"></div>
<button type="submit">Submit</button>
</form>
</body>
</html>Server-Side (endpoint + input name change):
app.post('/submit', async (req, res) => {
// ✅ Change input name
const token = req.body['cf-turnstile-response'];
const formData = new FormData();
formData.append('secret', process.env.TURNSTILE_SECRET_KEY);
formData.append('response', token);
formData.append('remoteip', req.ip);
// ✅ Change endpoint
const result = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: formData,
}
);
const outcome = await result.json();
if (!outcome.success) {
return res.status(401).send('Verification failed');
}
res.send('Success!');
});---
Additional Resources
- Turnstile Docs: https://developers.cloudflare.com/turnstile/
- reCAPTCHA Migration: https://developers.cloudflare.com/turnstile/migration/recaptcha/
- hCaptcha Migration: https://developers.cloudflare.com/turnstile/migration/hcaptcha/
- Siteverify API: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
- Testing Guide: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
---
Migration Support: For issues, check error-codes.md reference or consult Cloudflare Community Forums.
Estimated Migration Time: 30-60 minutes for reCAPTCHA v2 (compat mode), 60-120 minutes for hCaptcha (full migration).
Token Savings: ~70% reduction in implementation complexity vs manual migration without this guide.
Turnstile Mobile Implementation Guide
Comprehensive guide for integrating Cloudflare Turnstile in native mobile applications using WebView.
Last Updated: 2025-12-27 Official Docs: https://developers.cloudflare.com/turnstile/get-started/mobile-implementation/
---
Table of Contents
1. Overview 2. Core Requirements 3. Android WebView Integration 4. iOS WKWebView Integration 5. React Native Integration 6. Flutter Integration 7. Critical Implementation Issues 8. Testing Mobile Integration 9. Troubleshooting
---
Overview
Turnstile operates in standard browser environments, including native mobile applications through WebView components. WebViews allow native apps to display web content while maintaining app context.
WebView Definition: Components that allow native mobile applications to display web content within the app interface.
Supported Platforms:
- ✅ Android WebView
- ✅ iOS WKWebView
- ✅ React Native (react-native-webview)
- ✅ Flutter (InAppWebView)
---
Core Requirements
All WebView implementations must meet these requirements for Turnstile to function:
JavaScript & Storage
Mandatory:
- ✅ JavaScript execution enabled
- ✅ DOM storage API available (localStorage, sessionStorage)
- ✅ Standard web APIs accessible (Fetch, XMLHttpRequest)
Network Access
Required Domains:
- ✅
challenges.cloudflare.com(widget scripts and validation) - ✅
about:blank(internal iframe usage) - ✅
about:srcdoc(inline content)
Protocols:
- ✅ HTTP/HTTPS support
- ✅ WebSocket support (optional, for advanced features)
Environment Stability
Critical Requirements:
- ✅ Consistent User Agent throughout sessions (changing UA causes failures)
- ✅ Stable device characteristics (screen size, orientation)
- ✅ Persistent cookies (session-based storage required)
---
Android WebView Integration
Step 1: Enable WebView Features
Configure WebView with required settings:
import android.webkit.WebView;
import android.webkit.WebSettings;
public class MainActivity extends AppCompatActivity {
private WebView webView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
webView = findViewById(R.id.webview);
WebSettings webSettings = webView.getSettings();
// ✅ REQUIRED: Enable JavaScript
webSettings.setJavaScriptEnabled(true);
// ✅ REQUIRED: Enable DOM storage
webSettings.setDomStorageEnabled(true);
// ✅ RECOMMENDED: Enable overview mode
webSettings.setLoadWithOverviewMode(true);
// ✅ RECOMMENDED: Enable wide viewport
webSettings.setUseWideViewPort(true);
// ✅ RECOMMENDED: Enable zoom controls (UX)
webSettings.setBuiltInZoomControls(true);
webSettings.setDisplayZoomControls(false);
// Load your protected form
webView.loadUrl("https://example.com/protected-form");
}
}---
Step 2: Configure WebView Client
Handle navigation and SSL errors:
import android.webkit.WebViewClient;
import android.webkit.WebResourceRequest;
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
// Allow navigation to challenges.cloudflare.com
String url = request.getUrl().toString();
if (url.contains("challenges.cloudflare.com")) {
return false; // Let WebView handle it
}
// Handle other URLs as needed
return super.shouldOverrideUrlLoading(view, request);
}
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
// ⚠️ WARNING: Don't proceed with SSL errors in production
// Only for development/testing
// handler.proceed();
// ✅ PRODUCTION: Cancel on SSL errors
handler.cancel();
}
});---
Step 3: Handle Cookies
Ensure cookies persist across sessions:
import android.webkit.CookieManager;
CookieManager cookieManager = CookieManager.getInstance();
// ✅ Enable cookies
cookieManager.setAcceptCookie(true);
// ✅ Enable third-party cookies (required for Turnstile)
cookieManager.setAcceptThirdPartyCookies(webView, true);---
Step 4: Configure Content Security Policy
If using CSP headers, allow Cloudflare domains:
webView.setWebChromeClient(new WebChromeClient() {
@Override
public void onConsoleMessage(ConsoleMessage consoleMessage) {
// Monitor CSP violations
if (consoleMessage.message().contains("Content Security Policy")) {
Log.w("CSP", "Violation: " + consoleMessage.message());
}
}
});CSP Configuration (server-side):
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
connect-src 'self' https://challenges.cloudflare.com;">---
Complete Android Example
package com.example.turnstile;
import android.os.Bundle;
import android.webkit.WebView;
import android.webkit.WebSettings;
import android.webkit.WebViewClient;
import android.webkit.CookieManager;
import androidx.appcompat.app.AppCompatActivity;
public class MainActivity extends AppCompatActivity {
private WebView webView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
webView = findViewById(R.id.webview);
// Configure WebView settings
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setLoadWithOverviewMode(true);
settings.setUseWideViewPort(true);
// Configure cookies
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, true);
// Set WebView client
webView.setWebViewClient(new WebViewClient());
// Load protected page
webView.loadUrl("https://example.com/turnstile-protected-form");
}
@Override
public void onBackPressed() {
if (webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
}AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config">
...
</application>---
iOS WKWebView Integration
Step 1: Import WKWebView
import UIKit
import WebKit
class ViewController: UIViewController, WKNavigationDelegate {
var webView: WKWebView!
override func loadView() {
// ✅ Initialize WKWebViewConfiguration
let webConfiguration = WKWebViewConfiguration()
// ✅ REQUIRED: Enable JavaScript
webConfiguration.preferences.javaScriptEnabled = true
// ✅ RECOMMENDED: Enable inline media playback
webConfiguration.allowsInlineMediaPlayback = true
// Create WKWebView with configuration
webView = WKWebView(frame: .zero, configuration: webConfiguration)
webView.navigationDelegate = self
view = webView
}
override func viewDidLoad() {
super.viewDidLoad()
// Load protected page
let url = URL(string: "https://example.com/protected-form")!
let request = URLRequest(url: url)
webView.load(request)
}
}---
Step 2: Handle Navigation
extension ViewController {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
// Allow navigation to challenges.cloudflare.com
if let host = navigationAction.request.url?.host {
if host.contains("challenges.cloudflare.com") {
decisionHandler(.allow)
return
}
}
// Default: allow
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("Page loaded successfully")
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("Navigation failed: \(error.localizedDescription)")
}
}---
Step 3: Configure Cookie Storage
import WebKit
let dataStore = WKWebsiteDataStore.default()
let cookieStore = dataStore.httpCookieStore
// ✅ Cookies are enabled by default in WKWebView
// Ensure cookies persist across sessionsCustom User Agent (if needed):
webView.customUserAgent = "MyApp/1.0 (iOS; compatible; Turnstile)"⚠️ CRITICAL: Once set, never change User Agent during the session. Changing UA causes Turnstile to fail.
---
Step 4: Handle Content Security Policy
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// Inject CSP meta tag if needed
let cspScript = """
var meta = document.createElement('meta');
meta.httpEquiv = 'Content-Security-Policy';
meta.content = "script-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com;";
document.head.appendChild(meta);
"""
webView.evaluateJavaScript(cspScript) { result, error in
if let error = error {
print("CSP injection error: \(error)")
}
}
}---
Complete iOS Example
import UIKit
import WebKit
class TurnstileViewController: UIViewController, WKNavigationDelegate {
var webView: WKWebView!
override func loadView() {
let config = WKWebViewConfiguration()
config.preferences.javaScriptEnabled = true
config.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = self
view = webView
}
override func viewDidLoad() {
super.viewDidLoad()
// Load protected form
let url = URL(string: "https://example.com/turnstile-form")!
webView.load(URLRequest(url: url))
}
// MARK: - WKNavigationDelegate
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
print("Turnstile page loaded")
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("Load error: \(error.localizedDescription)")
}
}Info.plist (allow network access):
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSExceptionDomains</key>
<dict>
<key>challenges.cloudflare.com</key>
<dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
<false/>
</dict>
</dict>
</dict>---
React Native Integration
Step 1: Install react-native-webview
npm install react-native-webview
# or
yarn add react-native-webviewiOS: Run cd ios && pod install
---
Step 2: Implement WebView Component
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { WebView } from 'react-native-webview';
export default function TurnstileWebView() {
return (
<View style={styles.container}>
<WebView
source={{ uri: 'https://example.com/protected-form' }}
// ✅ REQUIRED: Enable JavaScript
javaScriptEnabled={true}
// ✅ REQUIRED: Enable DOM storage
domStorageEnabled={true}
// ✅ RECOMMENDED: Enable third-party cookies
thirdPartyCookiesEnabled={true}
// ✅ RECOMMENDED: Allow file access
allowFileAccess={true}
// Handle load events
onLoadStart={() => console.log('Loading Turnstile page...')}
onLoadEnd={() => console.log('Turnstile page loaded')}
onError={(syntheticEvent) => {
const { nativeEvent } = syntheticEvent;
console.error('WebView error:', nativeEvent);
}}
// Handle messages from web content (optional)
onMessage={(event) => {
const message = event.nativeEvent.data;
console.log('Message from WebView:', message);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});---
Step 3: Handle Turnstile Token (Optional)
Communicate between web content and React Native:
Web Page (inject script):
// In your HTML form
turnstile.render('#turnstile-widget', {
sitekey: 'YOUR_SITEKEY',
callback: function(token) {
// Send token to React Native
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'turnstile_success',
token: token
}));
}
},
'error-callback': function(error) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'turnstile_error',
error: error
}));
}
}
});React Native (receive messages):
<WebView
source={{ uri: 'https://example.com/protected-form' }}
javaScriptEnabled={true}
domStorageEnabled={true}
onMessage={(event) => {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'turnstile_success') {
console.log('Turnstile token:', data.token);
// Handle token (send to backend, enable submission, etc.)
} else if (data.type === 'turnstile_error') {
console.error('Turnstile error:', data.error);
}
}}
/>---
Flutter Integration
Step 1: Add flutter_inappwebview Dependency
pubspec.yaml:
dependencies:
flutter_inappwebview: ^5.8.0Run: flutter pub get
---
Step 2: Implement InAppWebView
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
class TurnstilePage extends StatefulWidget {
@override
_TurnstilePageState createState() => _TurnstilePageState();
}
class _TurnstilePageState extends State<TurnstilePage> {
late InAppWebViewController webViewController;
double progress = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Turnstile Protected Form')),
body: Column(
children: [
progress < 1.0
? LinearProgressIndicator(value: progress)
: Container(),
Expanded(
child: InAppWebView(
initialUrlRequest: URLRequest(
url: Uri.parse('https://example.com/protected-form'),
),
// ✅ REQUIRED: Enable JavaScript
initialOptions: InAppWebViewGroupOptions(
crossPlatform: InAppWebViewOptions(
javaScriptEnabled: true,
useShouldOverrideUrlLoading: true,
),
// Android-specific options
android: AndroidInAppWebViewOptions(
domStorageEnabled: true, // ✅ REQUIRED
useHybridComposition: true,
thirdPartyCookiesEnabled: true,
),
// iOS-specific options
ios: IOSInAppWebViewOptions(
allowsInlineMediaPlayback: true,
),
),
onWebViewCreated: (controller) {
webViewController = controller;
},
onLoadStart: (controller, url) {
print('Page started loading: $url');
},
onLoadStop: (controller, url) async {
print('Page finished loading: $url');
},
onProgressChanged: (controller, progress) {
setState(() {
this.progress = progress / 100;
});
},
onConsoleMessage: (controller, consoleMessage) {
print('Console: ${consoleMessage.message}');
},
),
),
],
),
);
}
}---
Step 3: Handle Messages (Optional)
onWebViewCreated: (controller) {
webViewController = controller;
// Add JavaScript handler
controller.addJavaScriptHandler(
handlerName: 'turnstileHandler',
callback: (args) {
// Receive messages from web page
print('Turnstile message: $args');
if (args.isNotEmpty) {
final data = args[0];
if (data['type'] == 'turnstile_success') {
print('Token: ${data['token']}');
}
}
},
);
},Web Page (send to Flutter):
turnstile.render('#turnstile-widget', {
sitekey: 'YOUR_SITEKEY',
callback: function(token) {
// Send to Flutter
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('turnstileHandler', {
type: 'turnstile_success',
token: token
});
}
}
});---
Critical Implementation Issues
Issue 1: User Agent Consistency (CRITICAL)
Problem: Changing User Agent during session causes Turnstile failures.
Cause: Turnstile validates User Agent consistency as part of authenticity checks.
Solution:
// ❌ WRONG: Changing UA mid-session
webView.setCustomUserAgent("MyApp/1.0");
// Later...
webView.setCustomUserAgent("MyApp/2.0"); // ⚠️ CAUSES FAILURE
// ✅ CORRECT: Set UA once, never change
webView.setCustomUserAgent("MyApp/1.0");Recommendation: Set User Agent at WebView initialization, never modify during session.
---
Issue 2: Cookie Persistence
Problem: Cookies don't persist between sessions, causing repeated challenges.
Cause: WebView not configured to store cookies persistently.
Solution (Android):
CookieManager.getInstance().setAcceptCookie(true);
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true);Solution (iOS):
// Cookies persist by default in WKWebView
// Use default WKWebsiteDataStore
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()---
Issue 3: Content Security Policy Blocking
Problem: CSP blocks challenges.cloudflare.com iframe/scripts.
Cause: Restrictive CSP headers don't whitelist Cloudflare domains.
Solution: Configure server-side CSP headers:
<meta http-equiv="Content-Security-Policy" content="
script-src 'self' https://challenges.cloudflare.com;
frame-src 'self' https://challenges.cloudflare.com;
connect-src 'self' https://challenges.cloudflare.com;">---
Issue 4: Domain Whitelisting
Problem: WebView blocks navigation to challenges.cloudflare.com.
Cause: Navigation policies reject external domains.
Solution: Allow navigation to Cloudflare domains:
Android:
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
if (url.contains("challenges.cloudflare.com")) {
return false; // Allow
}
return super.shouldOverrideUrlLoading(view, request);
}
});iOS:
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let host = navigationAction.request.url?.host, host.contains("challenges.cloudflare.com") {
decisionHandler(.allow)
} else {
decisionHandler(.allow)
}
}---
Testing Mobile Integration
Testing Checklist
Functionality:
- [ ] Widget loads correctly
- [ ] Challenge completes successfully
- [ ] Token is generated
- [ ] Token validates on server
- [ ] Error callbacks work
Platforms:
- [ ] Android (latest)
- [ ] Android (older versions: 8.0, 9.0)
- [ ] iOS (latest)
- [ ] iOS (older versions: 14.0, 15.0)
Network Conditions:
- [ ] WiFi
- [ ] 4G/5G
- [ ] Slow 3G (simulated)
- [ ] Offline → online transition
Orientation:
- [ ] Portrait mode
- [ ] Landscape mode
- [ ] Rotation during challenge
---
Debug Logging
Android:
WebView.setWebContentsDebuggingEnabled(true); // Enable Chrome DevTools
webView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onConsoleMessage(ConsoleMessage cm) {
Log.d("WebView", cm.message() + " -- Line " + cm.lineNumber() + " of " + cm.sourceId());
return true;
}
});iOS:
// Enable Web Inspector (Safari → Develop → [Device])
// No code changes neededReact Native:
<WebView
onMessage={(event) => console.log('WebView:', event.nativeEvent.data)}
onError={(syntheticEvent) => console.error('Error:', syntheticEvent.nativeEvent)}
/>---
Troubleshooting
Widget Doesn't Load
Check:
- [ ] JavaScript enabled
- [ ] DOM storage enabled
- [ ] Network access to
challenges.cloudflare.com - [ ] CSP allows Cloudflare domains
- [ ] User Agent consistency
Debug:
// Inject debug script
webView.evaluateJavaScript("console.log('JS enabled:', typeof turnstile !== 'undefined')");---
Token Validation Fails
Check:
- [ ] Token sent to server correctly
- [ ] Server using correct secret key
- [ ] Token not expired (5 min TTL)
- [ ] Token not reused (single-use)
---
Repeated Challenges
Check:
- [ ] Cookies persisting between sessions
- [ ] Third-party cookies enabled
- [ ] User Agent consistency
- [ ]
cf_clearancecookie not blocked
---
Additional Resources
- Turnstile Mobile Docs: https://developers.cloudflare.com/turnstile/get-started/mobile-implementation/
- Android WebView: https://developer.android.com/guide/webapps/webview
- iOS WKWebView: https://developer.apple.com/documentation/webkit/wkwebview
- react-native-webview: https://github.com/react-native-webview/react-native-webview
- flutter_inappwebview: https://github.com/pichillilorenzo/flutter_inappwebview
---
Best Practice: Test mobile integration thoroughly across platforms and network conditions before production deployment.
Token Savings: ~80% reduction in mobile implementation complexity vs manual WebView configuration.
React Integration Guide
Best practices for integrating Turnstile with React, Next.js, and modern React patterns
Recommended Package: @marsidev/react-turnstile (Cloudflare-verified)
---
Package Installation
npm install @marsidev/react-turnstile
# or
pnpm add @marsidev/react-turnstile
# or
yarn add @marsidev/react-turnstileCurrent Version: 1.3.1 (September 2025) React Compatibility: React 18+, Next.js 13+, 14+, 15+
---
Basic Usage
import { Turnstile } from '@marsidev/react-turnstile'
import { useState } from 'react'
export function ContactForm() {
const [token, setToken] = useState<string>()
return (
<form>
<input name="email" type="email" required />
<textarea name="message" required />
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
/>
<button disabled={!token}>Submit</button>
</form>
)
}---
Props Reference
Required Props
siteKey
Type: string Description: Your Turnstile sitekey
<Turnstile siteKey="YOUR_SITE_KEY" />Optional Props
onSuccess
Type: (token: string) => void Description: Called when challenge succeeds
<Turnstile onSuccess={(token) => console.log(token)} />onError
Type: (error: string) => void Description: Called when challenge fails
<Turnstile onError={(error) => console.error(error)} />onExpire
Type: () => void Description: Called when token expires (5 min)
<Turnstile onExpire={() => setToken(undefined)} />onAbort
Type: () => void Description: Called when challenge is aborted
options
Type: TurnstileOptions Description: Widget configuration
<Turnstile
siteKey="..."
options={{
theme: 'dark',
size: 'compact',
action: 'login',
}}
/>---
Using Refs
Access widget instance for manual control:
import { useRef } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
export function AdvancedForm() {
const turnstileRef = useRef<TurnstileInstance>(null)
function handleReset() {
turnstileRef.current?.reset()
}
function handleRemove() {
turnstileRef.current?.remove()
}
return (
<>
<Turnstile ref={turnstileRef} siteKey="..." />
<button onClick={handleReset}>Reset</button>
<button onClick={handleRemove}>Remove</button>
</>
)
}---
Next.js App Router
Client Component
// app/contact/page.tsx
'use client'
import { Turnstile } from '@marsidev/react-turnstile'
import { useState } from 'react'
export default function ContactPage() {
const [token, setToken] = useState<string>()
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.get('email'),
message: formData.get('message'),
'cf-turnstile-response': token,
}),
})
if (response.ok) {
alert('Success!')
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
onExpire={() => setToken(undefined)}
/>
<button disabled={!token}>Submit</button>
</form>
)
}Server Action (Next.js 14+)
'use server'
import { validateTurnstile } from '@/lib/turnstile'
export async function submitContact(formData: FormData) {
const token = formData.get('cf-turnstile-response')?.toString()
if (!token) {
return { error: 'Missing verification' }
}
const result = await validateTurnstile(token, process.env.TURNSTILE_SECRET_KEY!)
if (!result.success) {
return { error: 'Verification failed' }
}
// Process form
return { success: true }
}---
Next.js Pages Router
Page Component
// pages/contact.tsx
import { Turnstile } from '@marsidev/react-turnstile'
import { useState } from 'react'
export default function ContactPage() {
const [token, setToken] = useState<string>()
return (
<form>
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
/>
</form>
)
}API Route
// pages/api/contact.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { validateTurnstile } from '@/lib/turnstile'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const token = req.body['cf-turnstile-response']
if (!token) {
return res.status(400).json({ error: 'Missing token' })
}
const result = await validateTurnstile(
token,
process.env.TURNSTILE_SECRET_KEY!,
{
remoteip: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress,
}
)
if (!result.success) {
return res.status(401).json({ error: 'Invalid token' })
}
// Process form
res.status(200).json({ success: true })
}---
Custom Hook Pattern
// hooks/useTurnstile.ts
import { useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
export function useTurnstile(siteKey: string) {
const [token, setToken] = useState<string>()
const [isReady, setIsReady] = useState(false)
const [error, setError] = useState<string>()
const turnstileRef = useRef<TurnstileInstance>(null)
const reset = () => {
turnstileRef.current?.reset()
setToken(undefined)
setIsReady(false)
setError(undefined)
}
const TurnstileWidget = () => (
<Turnstile
ref={turnstileRef}
siteKey={siteKey}
onSuccess={(token) => {
setToken(token)
setIsReady(true)
setError(undefined)
}}
onError={(err) => {
setError(err)
setIsReady(false)
}}
onExpire={() => {
setToken(undefined)
setIsReady(false)
}}
/>
)
return {
token,
isReady,
error,
reset,
TurnstileWidget,
}
}
// Usage
export function MyForm() {
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile(
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
)
return (
<form>
<TurnstileWidget />
{error && <div>Error: {error}</div>}
<button disabled={!isReady}>Submit</button>
</form>
)
}---
Jest Testing
Mock Setup
// jest.setup.ts
import React from 'react'
jest.mock('@marsidev/react-turnstile', () => ({
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
React.useEffect(() => {
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
}, [onSuccess])
return <div data-testid="turnstile-mock" />
},
}))Component Test
// ContactForm.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { ContactForm } from './ContactForm'
test('submits form with Turnstile', async () => {
render(<ContactForm />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
// Turnstile auto-solves (mocked)
await waitFor(() => {
expect(submitButton).not.toBeDisabled()
})
fireEvent.click(submitButton)
expect(await screen.findByText('Success')).toBeInTheDocument()
})---
Environment-Aware Sitekey
// lib/turnstile.ts
export function useTurnstileSiteKey() {
// Test/Development: Use dummy key
if (process.env.NODE_ENV !== 'production') {
return '1x00000000000000000000AA'
}
// Production: Use real key
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
}
// Usage
import { useTurnstileSiteKey } from '@/lib/turnstile'
export function MyForm() {
const siteKey = useTurnstileSiteKey()
return <Turnstile siteKey={siteKey} />
}---
Known Issues & Workarounds
Issue #112: Next.js + Jest Compatibility (Oct 2025)
Problem: @marsidev/react-turnstile breaks Jest tests
Source: https://github.com/marsidev/react-turnstile/issues/112
Workaround: Mock the component (see Jest Testing section above)
Issue #113: Blocked Script Execution (Oct 2025)
Problem: Script execution blocked in some environments
Source: https://github.com/marsidev/react-turnstile/issues/113
Workaround: 1. Check CSP headers allow challenges.cloudflare.com 2. Ensure api.js loads from CDN (not proxied)
---
TypeScript Types
import type {
TurnstileInstance,
TurnstileProps,
TurnstileOptions,
} from '@marsidev/react-turnstile'
// Widget instance methods
interface TurnstileInstance {
reset(): void
remove(): void
execute(): void
getResponse(): string | undefined
isExpired(): boolean
}
// Component props
interface TurnstileProps {
siteKey: string
onSuccess?: (token: string) => void
onError?: (error: string) => void
onExpire?: () => void
onAbort?: () => void
options?: TurnstileOptions
scriptOptions?: {
nonce?: string
defer?: boolean
async?: boolean
}
}
// Widget options
interface TurnstileOptions {
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'
action?: string
cdata?: string
execution?: 'render' | 'execute'
appearance?: 'always' | 'execute' | 'interaction-only'
retry?: 'auto' | 'never'
'retry-interval'?: number
}---
Performance Optimization
Lazy Loading
import { lazy, Suspense } from 'react'
const Turnstile = lazy(() =>
import('@marsidev/react-turnstile').then(mod => ({ default: mod.Turnstile }))
)
export function LazyTurnstileForm() {
return (
<form>
<Suspense fallback={<div>Loading verification...</div>}>
<Turnstile siteKey="..." />
</Suspense>
</form>
)
}Conditional Rendering
Only render Turnstile when needed:
export function ConditionalForm() {
const [showTurnstile, setShowTurnstile] = useState(false)
return (
<form>
<input onChange={() => setShowTurnstile(true)} />
{showTurnstile && <Turnstile siteKey="..." />}
</form>
)
}---
Best Practices
✅ Use environment variables for sitekeys ✅ Mock in tests using Jest setup file ✅ Handle expiration with onExpire callback ✅ Disable submit until ready based on token state ✅ Reset after submission for multi-use forms ✅ Use TypeScript for type safety ✅ Lazy load if not immediately needed
❌ Don't hardcode sitekeys in components ❌ Don't skip error handling (onError) ❌ Don't forget server validation (critical!) ❌ Don't use production keys in tests
---
Last Updated: 2025-10-22 Package Version: @marsidev/react-turnstile@1.3.1 Cloudflare Status: ✅ Officially Recommended
Cloudflare Turnstile - Complete Setup Checklist
Last Updated: 2025-11-26
Use this comprehensive checklist to verify your Turnstile setup before deploying to production. Each item includes verification steps and links to relevant documentation.
---
Pre-Deployment Checklist
1. Widget Configuration ✓
- [ ] Created Turnstile widget in Cloudflare Dashboard
- Verify: Dashboard → Turnstile → Widget visible with sitekey/secret
- Link: https://dash.cloudflare.com/?to=/:account/turnstile
- [ ] Added allowed domains (including localhost for dev)
- Verify: Widget settings → Domains list includes all environments
- Dev:
localhost,127.0.0.1 - Staging:
staging.example.com - Production:
example.com,www.example.com
2. Frontend Integration ✓
- [ ] Frontend widget loads from Cloudflare CDN
- Verify:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"> - NEVER proxy or cache this script
- Must load directly from Cloudflare for security updates
- [ ] Widget renders with correct sitekey
- Verify: Widget appears on page, no console errors
- Check:
data-sitekey="YOUR_SITE_KEY"orsitekey: "YOUR_SITE_KEY"in code - Test: Widget shows challenge when interacted with
- [ ] Error callback implemented and tested
- Verify:
error-callbackhandler logs/displays errors - Test: Use invalid sitekey to trigger error callback
- User-friendly error message displayed (not just console.error)
3. Server-Side Validation ✓
- [ ] Server-side Siteverify validation implemented
- Verify: Backend calls
https://challenges.cloudflare.com/turnstile/v0/siteverify - CRITICAL: Never skip server validation (client-side only = security vulnerability)
- See:
references/common-patterns.mdPattern 1 for implementation
- [ ] Secret key stored in environment variable (not hardcoded)
- Verify:
env.TURNSTILE_SECRET_KEYused in validation - Check: No hardcoded secrets in source code
- Review:
.envin.gitignore
- [ ] Token validation includes remoteip check
- Verify:
remoteipparameter sent to Siteverify API - Cloudflare Workers: Use
request.headers.get('CF-Connecting-IP') - Other platforms: Use request IP address
4. Security & CSP ✓
- [ ] CSP allows challenges.cloudflare.com (if using CSP)
- Verify: No CSP-related errors in browser console
- Required directives:
script-src https://challenges.cloudflare.com
frame-src https://challenges.cloudflare.com
connect-src https://challenges.cloudflare.com- Tool: Run
scripts/check-csp.sh https://yoursite.com
5. Testing ✓
- [ ] Testing uses dummy sitekeys
- Verify: Test environment uses
1x00000000000000000000AA(always pass) - NEVER use production keys in tests (pollutes analytics + rate limits)
- See:
references/testing-guide.mdfor all dummy keys
- [ ] Token expiration handling implemented (5 min TTL)
- Verify:
expired-callbackresets widget or requests new token - Test: Wait 6 minutes after widget render, submit should fail gracefully
- User sees clear message about expired challenge
6. Accessibility ✓
- [ ] Widget accessibility tested
- Keyboard navigation: Tab to widget, Enter to activate
- Screen readers: Widget announces state changes
- WCAG 2.1 AA compliance verified
- Alternative text provided for challenge images (if interactive)
- [ ] Error states display user-friendly messages
- Verify: Error messages explain what went wrong
- Examples:
- "Please complete the security challenge"
- "Challenge expired, please try again"
- "Security verification failed, please refresh the page"
- AVOID technical error codes in user-facing messages
7. Environment Separation ✓
- [ ] Production deployment uses separate widget from dev/staging
- Verify: Different sitekeys for each environment
- Dev: Use dummy test keys (
1x00000000000000000000AA) - Staging: Dedicated staging widget
- Production: Dedicated production widget
- Why: Separate analytics, rate limits, and security posture per environment
---
Post-Deployment Verification
1. Functional Testing
- [ ] Submit form with valid challenge → Success
- [ ] Submit form without challenge → Rejection
- [ ] Submit form with expired token (> 5 min) → Rejection
- [ ] Trigger error callback → User sees friendly message
- [ ] Test on multiple browsers (Chrome, Firefox, Safari, Edge)
- [ ] Test on mobile devices (iOS, Android)
2. Analytics Verification
- [ ] Log into Cloudflare Dashboard → Turnstile → Analytics
- [ ] Verify challenge requests are being recorded
- [ ] Check solve rate (should be >95% for legitimate traffic)
- [ ] Monitor error rates (<5% expected)
- [ ] Review action breakdown (if using custom actions)
3. Performance Testing
- [ ] Widget loads quickly (<1s)
- [ ] No impact on page load time
- [ ] Challenge solves quickly (<2s for invisible/managed)
- [ ] No JavaScript errors in console
- [ ] No CSP violations
4. Security Verification
- [ ] Attempt to submit form without token → Rejected
- [ ] Attempt to reuse token → Rejected ("token already spent")
- [ ] Attempt to use token from different domain → Rejected
- [ ] Secret key not visible in client-side code
- [ ] remoteip validation working (test with VPN switch)
---
Common Issues Checklist
If experiencing issues, verify:
- [ ] Error 110200? → Add domain to widget allowlist
- [ ] Error 300030? → Implement error callback with retry logic
- [ ] Tokens failing validation? → Check token hasn't expired (5 min TTL)
- [ ] CSP blocking iframe? → Add frame-src directive
- [ ] Safari 18 errors? → Document "Hide IP" setting requirement
- [ ] Jest tests failing? → Mock @marsidev/react-turnstile component
Troubleshooting Guide: See references/error-codes.md for complete error reference
---
Deployment Workflow
1. Pre-Deployment
# 1. Run tests with dummy keys
npm test
# 2. Verify CSP configuration
./scripts/check-csp.sh https://staging.example.com
# 3. Test staging environment with real widget
# (Use staging sitekey, not production)2. Deployment
# 1. Deploy code
npm run deploy
# 2. Verify environment variables
# - TURNSTILE_SECRET_KEY set correctly
# - TURNSTILE_SITE_KEY (if stored server-side)
# 3. Smoke test production endpoint
curl -X POST https://api.example.com/contact \
-H "Content-Type: application/json" \
-d '{"cf-turnstile-response": "test-token"}'
# Should return 401 (token invalid but validation working)3. Post-Deployment
# 1. Monitor logs for Turnstile errors
tail -f /var/log/app.log | grep turnstile
# 2. Check Cloudflare Dashboard analytics
# Verify requests are being recorded
# 3. Test with real user flow
# Submit actual form and verify success---
Quick Reference
Dashboard: https://dash.cloudflare.com/?to=/:account/turnstile Siteverify API: https://challenges.cloudflare.com/turnstile/v0/siteverify Test Sitekey (Always Pass): 1x00000000000000000000AA Test Secret Key (Always Pass): 1x0000000000000000000000000000000AA
Related Documentation:
- Setup: See SKILL.md "Quick Start (10 Minutes)"
- Patterns: See
references/common-patterns.md - Testing: See
references/testing-guide.md - Errors: See
references/error-codes.md
---
Remember: The most common issue is missing server-side validation. Always validate tokens server-side with the Siteverify API!
{
"name": "my-turnstile-app",
"main": "src/index.ts",
"compatibility_date": "2025-10-22",
// Public sitekey - safe to commit to version control
// Use dummy keys for development, real keys for production
"vars": {
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA" // Test key - always passes
// Production: Replace with your real sitekey from https://dash.cloudflare.com/?to=/:account/turnstile
},
// Secret key - NEVER commit to version control
// Set using: wrangler secret put TURNSTILE_SECRET_KEY
"secrets": ["TURNSTILE_SECRET_KEY"],
// Optional: Environment-specific configuration
"env": {
"production": {
"vars": {
"TURNSTILE_SITE_KEY": "<YOUR_PRODUCTION_SITE_KEY>"
}
},
"staging": {
"vars": {
"TURNSTILE_SITE_KEY": "<YOUR_STAGING_SITE_KEY>"
}
},
"development": {
"vars": {
// Use test sitekey for development (always passes)
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA"
}
}
}
}