Cloudflare Turnstile
Status: Production Ready Last Updated: 2025-10-22 Dependencies: None (optional: @marsidev/react-turnstile for React) Latest Versions: @marsidev/react-turnstile@1.3.1, turnstile-types@1.2.3
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
- Log into Cloudflare Dashboard
- Navigate to Turnstile section
- Click "Add Site"
- 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 token
React 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
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 Analyti