cloudflare-turnstile — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited cloudflare-turnstile (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Status: Production Ready Last Updated: 2025-10-22 Dependencies: None (optional: @marsidev/react-turnstile for React) Latest Versions: @marsidev/[email protected], [email protected]
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:
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:
api.js - must load from Cloudflare CDNcf-turnstile-response with tokenALWAYS 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!')
}
}Key Points:
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>
)
}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 })
}✅ 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
❌ 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
This skill prevents 12 documented issues:
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
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
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
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
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
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
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
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)
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
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
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
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
{
"name": "my-app",
"main": "src/index.ts",
"compatibility_date": "2025-10-22",
// Public sitekey (safe to commit)
"vars": {
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA" // Use real key in production
},
// Secret key (DO NOT commit - use wrangler secret)
// Run: wrangler secret put TURNSTILE_SECRET_KEY
"secrets": ["TURNSTILE_SECRET_KEY"]
}Why these settings:
vars for public sitekey (visible in client code)secrets for private secret key (encrypted, backend-only)<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;
">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 appWhen to use: API routes in Cloudflare Workers with Hono framework
'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>
)
}When to use: Client-side forms in Next.js with React hooks
// test/helpers/turnstile.ts
export const TEST_TURNSTILE = {
sitekey: {
alwaysPass: '1x00000000000000000000AA',
alwaysBlock: '2x00000000000000000000AB',
invisible: '1x00000000000000000000BB',
interactive: '3x00000000000000000000FF',
},
secretKey: {
alwaysPass: '1x0000000000000000000000000000000AA',
alwaysFail: '2x0000000000000000000000000000000AA',
tokenSpent: '3x0000000000000000000000000000000AA',
},
dummyToken: 'XXXX.DUMMY.TOKEN.XXXX',
}
// 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"]', '[email protected]')
// Turnstile auto-solves with dummy token
await page.click('button[type="submit"]')
await expect(page.locator('.success')).toBeVisible()
})When to use: Automated testing (Playwright, Cypress, Jest)
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),
})When to use: SPAs requiring programmatic widget control
Example Usage:
./scripts/check-csp.sh https://example.comreferences/widget-configs.md - Complete reference of all widget configuration optionsreferences/error-codes.md - Comprehensive error code reference with troubleshootingreferences/testing-guide.md - Testing strategies, dummy keys, E2E patternsreferences/react-integration.md - React-specific patterns and @marsidev/react-turnstile usageWhen Claude should load these:
widget-configs.md: When configuring widget appearance, themes, or execution modeserror-codes.md: When debugging error codes 100, 200, 300, 400, 600*testing-guide.md: When setting up E2E tests or local developmentreact-integration.md: When integrating with React, Next.js, or encountering React-specific issueswrangler-turnstile-config.jsonc - Cloudflare Workers environment configurationturnstile-widget-implicit.html - Implicit rendering HTML exampleturnstile-widget-explicit.ts - Explicit rendering JavaScript APIturnstile-server-validation.ts - Siteverify API validation functionturnstile-react-component.tsx - React component using @marsidev/react-turnstileturnstile-hono-route.ts - Hono route handler with validationturnstile-test-config.ts - Testing configuration with dummy keysTurnstile can issue a pre-clearance cookie that persists across page navigations in single-page applications.
turnstile.render('#container', {
sitekey: SITE_KEY,
callback: async (token) => {
// Request pre-clearance cookie
await fetch('/api/pre-clearance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
},
})Track different challenge types or pass custom data:
turnstile.render('#container', {
sitekey: SITE_KEY,
action: 'login', // Track action in analytics
cdata: JSON.stringify({ userId: '123' }), // Custom data (max 255 chars)
callback: (token) => {
// Token includes action and cdata for server validation
},
})Server-side verification:
const result = await validateTurnstile(token, secretKey)
if (result.action !== 'login') {
return new Response('Invalid action', { status: 400 })
}
const customData = JSON.parse(result.cdata || '{}')class TurnstileWithRetry {
private retryCount = 0
private maxRetries = 3
render(containerId: string) {
turnstile.render(containerId, {
sitekey: SITE_KEY,
retry: 'auto', // or 'never' for manual control
'retry-interval': 8000, // ms between retries
'error-callback': (error) => {
this.handleError(error)
},
})
}
private handleError(error: string) {
// Error codes that should not retry
const noRetry = ['110100', '110200', '110500']
if (noRetry.some(code => error.includes(code))) {
this.showFallback()
return
}
// Retry on transient errors
if (this.retryCount < this.maxRetries) {
this.retryCount++
setTimeout(() => {
turnstile.reset(this.widgetId)
}, 2000 * this.retryCount) // Exponential backoff
} else {
this.showFallback()
}
}
private showFallback() {
// Show alternative verification method
console.error('Turnstile failed - showing fallback')
}
}const widgets = {
login: null as string | null,
signup: null as string | null,
}
// Render multiple widgets
widgets.login = turnstile.render('#login-widget', {
sitekey: SITE_KEY,
action: 'login',
})
widgets.signup = turnstile.render('#signup-widget', {
sitekey: SITE_KEY,
action: 'signup',
})
// Reset specific widget
turnstile.reset(widgets.login)
// Get token from specific widget
const loginToken = turnstile.getResponse(widgets.login)Required:
Optional (React):
@marsidev/[email protected] - Official Cloudflare-recommended React integration[email protected] - TypeScript type definitionsOptional (Other Frameworks):
vue-turnstile - Vue 3 integrationcfturnstile-vue3 - Alternative Vue 3 wrapperngx-turnstile - Angular integrationsvelte-turnstile - Svelte integration@nuxtjs/turnstile - Nuxt full-stack integrationmcp__cloudflare-docs__search_cloudflare_documentation tool{
"devDependencies": {
"@marsidev/react-turnstile": "^1.3.1",
"turnstile-types": "^1.2.3"
}
}Notes:
This skill is based on production implementations:
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.
Solution: Implement error callback with retry logic. This is a known Cloudflare-side issue (2025). Fallback to alternative verification if retries fail.
success: falseSolution:
Solution: Add CSP directives:
<meta http-equiv="Content-Security-Policy" content="
frame-src https://challenges.cloudflare.com;
script-src https://challenges.cloudflare.com;
">Solution: Document in error message that users should disable Safari's "Hide IP address" setting (Safari → Settings → Privacy → Hide IP address → Off)
Solution: Mock the Turnstile component in Jest setup:
// jest.setup.ts
jest.mock('@marsidev/react-turnstile', () => ({
Turnstile: () => <div data-testid="turnstile-mock" />,
}))Use this checklist to verify your setup:
https://challenges.cloudflare.com/turnstile/v0/api.js1x00000000000000000000AA)Questions? Issues?
references/error-codes.md for specific error troubleshootingmcp__cloudflare-docs__search_cloudflare_documentationToken Efficiency: ~65-70% savings (10-12k tokens → 3-4k tokens)
Errors Prevented: 12 documented issues with complete solutions
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.