433 lines
15 KiB
Markdown
433 lines
15 KiB
Markdown
---
|
|
name: cloudflare-turnstile
|
|
description: |
|
|
Add bot protection with Turnstile (CAPTCHA alternative). Use when: protecting forms, securing login/signup, preventing spam, migrating from reCAPTCHA, integrating with React/Next.js/Hono, implementing E2E tests, or debugging CSP errors, token validation failures, or error codes 100*/300*/600*.
|
|
license: MIT
|
|
---
|
|
|
|
# Cloudflare Turnstile
|
|
|
|
**Status**: Production Ready ✅
|
|
**Last Updated**: 2025-11-24
|
|
**Dependencies**: None (optional: @marsidev/react-turnstile for React)
|
|
**Latest Versions**: @marsidev/react-turnstile@1.3.1, turnstile-types@1.2.3
|
|
|
|
**Recent Updates (2025)**:
|
|
- **March 2025**: Upgraded Turnstile Analytics with TopN statistics (7 dimensions: hostnames, browsers, countries, user agents, ASNs, OS, source IPs), anomaly detection, enhanced bot behavior monitoring
|
|
- **2025**: WCAG 2.1 AA compliance, Free plan (20 widgets, 7-day analytics), Enterprise features (unlimited widgets, ephemeral IDs, any hostname support, 30-day analytics, offlabel branding)
|
|
|
|
---
|
|
|
|
## Quick Start (5 Minutes)
|
|
|
|
```bash
|
|
# 1. Create widget: https://dash.cloudflare.com/?to=/:account/turnstile
|
|
# Copy sitekey (public) and secret key (private)
|
|
|
|
# 2. Add widget to frontend
|
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
<form>
|
|
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
|
|
<button type="submit">Submit</button>
|
|
</form>
|
|
|
|
# 3. Validate token server-side (Cloudflare Workers)
|
|
const formData = await request.formData()
|
|
const token = formData.get('cf-turnstile-response')
|
|
|
|
const verifyFormData = new FormData()
|
|
verifyFormData.append('secret', env.TURNSTILE_SECRET_KEY)
|
|
verifyFormData.append('response', token)
|
|
verifyFormData.append('remoteip', request.headers.get('CF-Connecting-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', { status: 401 })
|
|
```
|
|
|
|
**CRITICAL:**
|
|
- Token expires in 5 minutes, single-use only
|
|
- ALWAYS validate server-side (Siteverify API required)
|
|
- Never proxy/cache api.js (must load from Cloudflare CDN)
|
|
- Use different widgets for dev/staging/production
|
|
|
|
## Rendering Modes
|
|
|
|
**Implicit** (auto-render on page load):
|
|
```html
|
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-callback="onSuccess"></div>
|
|
```
|
|
|
|
**Explicit** (programmatic control for SPAs):
|
|
```typescript
|
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
|
const widgetId = turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY' })
|
|
turnstile.reset(widgetId) // Reset widget
|
|
turnstile.getResponse(widgetId) // Get token
|
|
```
|
|
|
|
**React** (using @marsidev/react-turnstile):
|
|
```tsx
|
|
import { Turnstile } from '@marsidev/react-turnstile'
|
|
<Turnstile siteKey={TURNSTILE_SITE_KEY} onSuccess={setToken} />
|
|
```
|
|
|
|
---
|
|
|
|
## 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 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.jsonc:**
|
|
```jsonc
|
|
{
|
|
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
|
|
"secrets": ["TURNSTILE_SECRET_KEY"] // Run: wrangler secret put TURNSTILE_SECRET_KEY
|
|
}
|
|
```
|
|
|
|
**Required CSP:**
|
|
```html
|
|
<meta http-equiv="Content-Security-Policy" content="
|
|
script-src 'self' https://challenges.cloudflare.com;
|
|
frame-src 'self' https://challenges.cloudflare.com;
|
|
">
|
|
```
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Pattern 1: Hono + Cloudflare Workers
|
|
|
|
```typescript
|
|
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 app
|
|
```
|
|
|
|
**When to use**: API routes in Cloudflare Workers with Hono framework
|
|
|
|
### Pattern 2: React + Next.js App Router
|
|
|
|
```tsx
|
|
'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
|
|
|
|
---
|
|
|
|
## Testing Keys
|
|
|
|
**Dummy Sitekeys (client):**
|
|
- Always pass: `1x00000000000000000000AA`
|
|
- Always block: `2x00000000000000000000AB`
|
|
- Force interactive: `3x00000000000000000000FF`
|
|
|
|
**Dummy Secret Keys (server):**
|
|
- Always pass: `1x0000000000000000000000000000000AA`
|
|
- Always fail: `2x0000000000000000000000000000000AA`
|
|
- Token already spent: `3x0000000000000000000000000000000AA`
|
|
|
|
---
|
|
|
|
## Bundled Resources
|
|
|
|
**Scripts:** `check-csp.sh` - Verify CSP allows Turnstile
|
|
|
|
**References:**
|
|
- `widget-configs.md` - All configuration options
|
|
- `error-codes.md` - Error code troubleshooting (100*/200*/300*/400*/600*)
|
|
- `testing-guide.md` - Testing strategies, dummy keys
|
|
- `react-integration.md` - React/Next.js patterns
|
|
|
|
**Templates:** Complete examples for Hono, React, implicit/explicit rendering, validation
|
|
|
|
---
|
|
|
|
## Advanced Features
|
|
|
|
**Pre-Clearance (SPAs):** Issue cookie that persists across page navigations
|
|
```typescript
|
|
turnstile.render('#container', {
|
|
sitekey: SITE_KEY,
|
|
callback: async (token) => {
|
|
await fetch('/api/pre-clearance', { method: 'POST', body: JSON.stringify({ token }) })
|
|
}
|
|
})
|
|
```
|
|
|
|
**Custom Actions & Data:** Track challenge types, pass custom data (max 255 chars)
|
|
```typescript
|
|
turnstile.render('#container', {
|
|
action: 'login', // Track in analytics
|
|
cdata: JSON.stringify({ userId: '123' }), // Custom payload
|
|
})
|
|
```
|
|
|
|
**Error Handling:** Use `retry: 'auto'` and `error-callback` for resilience
|
|
```typescript
|
|
turnstile.render('#container', {
|
|
retry: 'auto',
|
|
'retry-interval': 8000, // ms between retries
|
|
'error-callback': (error) => { /* handle or show fallback */ }
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencies
|
|
|
|
**Required:** None (loads from CDN)
|
|
**React:** @marsidev/react-turnstile@1.3.1 (Cloudflare-recommended), turnstile-types@1.2.3
|
|
**Other:** vue-turnstile, ngx-turnstile, svelte-turnstile, @nuxtjs/turnstile
|
|
|
|
---
|
|
|
|
## Official Documentation
|
|
|
|
- https://developers.cloudflare.com/turnstile/
|
|
- 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:
|
|
```html
|
|
<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:
|
|
```typescript
|
|
// jest.setup.ts
|
|
jest.mock('@marsidev/react-turnstile', () => ({
|
|
Turnstile: () => <div data-testid="turnstile-mock" />,
|
|
}))
|
|
```
|
|
|
|
---
|
|
|
|
**Errors Prevented**: 12 documented issues (Safari 18 Hide IP, Brave confetti, Next.js Jest, CSP blocking, token reuse, expiration, hostname allowlist, widget crash 300030, config error 600010, missing validation, GET request, secret exposure)
|