Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:24:31 +08:00
commit 0aa89c365d
19 changed files with 4303 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"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*.",
"version": "1.0.0",
"author": {
"name": "Jeremy Dawes",
"email": "jeremy@jezweb.net"
},
"skills": [
"./"
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# cloudflare-turnstile
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*.

432
SKILL.md Normal file
View File

@@ -0,0 +1,432 @@
---
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)

View File

@@ -0,0 +1,14 @@
[TODO: Example Template File]
[TODO: This directory contains files that will be used in the OUTPUT that Claude produces.]
[TODO: Examples:]
- Templates (.html, .tsx, .md)
- Images (.png, .svg)
- Fonts (.ttf, .woff)
- Boilerplate code
- Configuration file templates
[TODO: Delete this file and add your actual assets]
These files are NOT loaded into context. They are copied or used directly in the final output.

105
plugin.lock.json Normal file
View File

@@ -0,0 +1,105 @@
{
"$schema": "internal://schemas/plugin.lock.v1.json",
"pluginId": "gh:jezweb/claude-skills:skills/cloudflare-turnstile",
"normalized": {
"repo": null,
"ref": "refs/tags/v20251128.0",
"commit": "395f7a3fbf640673b3e03ed800673c320bac4327",
"treeHash": "0d0340805a6c2ea84ae4fa5a4f1a52b687917fbaeef4731605eec19a822cdc3a",
"generatedAt": "2025-11-28T10:18:58.793082Z",
"toolVersion": "publish_plugins.py@0.2.0"
},
"origin": {
"remote": "git@github.com:zhongweili/42plugin-data.git",
"branch": "master",
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
},
"manifest": {
"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*.",
"version": "1.0.0"
},
"content": {
"files": [
{
"path": "README.md",
"sha256": "4be7157860d8e65e17bb70554607aeb8c8bc1b8172764d97df7edc3514f83724"
},
{
"path": "SKILL.md",
"sha256": "9644cf2b04733ca4ef638b11f1dcaff6e94db6a69f7fad9dcf6f996b4f25df84"
},
{
"path": "references/example-reference.md",
"sha256": "77c788d727d05d6479a61d6652b132e43882ffc67c145bb46ba880567d83f7f8"
},
{
"path": "references/widget-configs.md",
"sha256": "43d089076218ff08cc27716f09eaaed361bb7b5d9a99bff1467dceaf9271c016"
},
{
"path": "references/react-integration.md",
"sha256": "d2e21fdf4e85f5e269bcfba4c0b365b5273794d136af3ff030155a00551684fb"
},
{
"path": "references/error-codes.md",
"sha256": "fdf8c6a1ca3bf6b5eb3b9922939f2f6523bc3485e19fc5e58f60339971213365"
},
{
"path": "references/testing-guide.md",
"sha256": "3a0a1ec5258c712d8885d595f726514e94cf94ffab50745ff50be73aa404c4ce"
},
{
"path": "scripts/check-csp.sh",
"sha256": "7560516e85807369b9136653e909bf6c80369a4f136cd38964a9eda3f225639e"
},
{
"path": "scripts/example-script.sh",
"sha256": "83d2b09d044811608e17cbd8e66d993b1e9998c7bd3379a42ab81fbdba973e0e"
},
{
"path": ".claude-plugin/plugin.json",
"sha256": "c4d49a11df5616f13b7245275209495dc580d4d1fa09209001608b7f7534e128"
},
{
"path": "templates/turnstile-test-config.ts",
"sha256": "529ec05cba26f8c1c2076700977d0678bf7ffcc95be885d530e9548aac2012c9"
},
{
"path": "templates/turnstile-widget-implicit.html",
"sha256": "b59a17c9565df0b5d576f794b4afb144403763d9e4e025fe5e85082c7792113d"
},
{
"path": "templates/turnstile-react-component.tsx",
"sha256": "0ec5ab1d61fa1bb3bee9dd8c2ee35a47e1445865edabb18010689cd576c7e13c"
},
{
"path": "templates/turnstile-hono-route.ts",
"sha256": "0482db70722bf6f006dbd82e6dacef8c8a0f46250ff89cb2e6c803f9ab6c907f"
},
{
"path": "templates/turnstile-widget-explicit.ts",
"sha256": "acda3dcf63ff3b053517d74204a2074bb979e889da5a6c1c8b07af6337b9228c"
},
{
"path": "templates/turnstile-server-validation.ts",
"sha256": "17f9b65d7e7b6c7f47e916f84f39c7330c9962dde24eb76a0c656131bfe8d5cc"
},
{
"path": "templates/wrangler-turnstile-config.jsonc",
"sha256": "bf20c5cd1721dfaf37187101d3deaa25f291349c9b59eb409247ee618c199d3b"
},
{
"path": "assets/example-template.txt",
"sha256": "3f725c80d70847fd8272bf1400515ba753f12f98f3b294d09e50b54b4c1b024a"
}
],
"dirSha256": "0d0340805a6c2ea84ae4fa5a4f1a52b687917fbaeef4731605eec19a822cdc3a"
},
"security": {
"scannedAt": null,
"scannerVersion": null,
"flags": []
}
}

559
references/error-codes.md Normal file
View File

@@ -0,0 +1,559 @@
# 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**:
```typescript
// ❌ 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**:
```typescript
// ❌ 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**:
```typescript
{
'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.js` script
**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**:
```html
<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**:
```typescript
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**:
```typescript
// ❌ 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**:
```typescript
// ❌ 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.com` is 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.js` from CDN?
---
**Last Updated**: 2025-10-22
**Most Common Errors**: 110200 (domain), 200500 (CSP), 300030 (crash), 600010 (config)

View File

@@ -0,0 +1,26 @@
# [TODO: Reference Document Name]
[TODO: This file contains reference documentation that Claude can load when needed.]
[TODO: Delete this file if you don't have reference documentation to provide.]
## Purpose
[TODO: Explain what information this document contains]
## When Claude Should Use This
[TODO: Describe specific scenarios where Claude should load this reference]
## Content
[TODO: Add your reference content here - schemas, guides, specifications, etc.]
---
**Note**: This file is NOT loaded into context by default. Claude will only load it when:
- It determines the information is needed
- You explicitly ask Claude to reference it
- The SKILL.md instructions direct Claude to read it
Keep this file under 10k words for best performance.

View File

@@ -0,0 +1,550 @@
# 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
```bash
npm install @marsidev/react-turnstile
# or
pnpm add @marsidev/react-turnstile
# or
yarn add @marsidev/react-turnstile
```
**Current Version**: 1.3.1 (September 2025)
**React Compatibility**: React 18+, Next.js 13+, 14+, 15+
---
## Basic Usage
```tsx
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
```tsx
<Turnstile siteKey="YOUR_SITE_KEY" />
```
### Optional Props
#### `onSuccess`
**Type**: `(token: string) => void`
**Description**: Called when challenge succeeds
```tsx
<Turnstile onSuccess={(token) => console.log(token)} />
```
#### `onError`
**Type**: `(error: string) => void`
**Description**: Called when challenge fails
```tsx
<Turnstile onError={(error) => console.error(error)} />
```
#### `onExpire`
**Type**: `() => void`
**Description**: Called when token expires (5 min)
```tsx
<Turnstile onExpire={() => setToken(undefined)} />
```
#### `onAbort`
**Type**: `() => void`
**Description**: Called when challenge is aborted
#### `options`
**Type**: `TurnstileOptions`
**Description**: Widget configuration
```tsx
<Turnstile
siteKey="..."
options={{
theme: 'dark',
size: 'compact',
action: 'login',
}}
/>
```
---
## Using Refs
Access widget instance for manual control:
```tsx
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
```tsx
// 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+)
```tsx
'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
```tsx
// 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
```typescript
// 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
```tsx
// 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
```typescript
// 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
```tsx
// 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
```tsx
// 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
```typescript
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
```tsx
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:
```tsx
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

430
references/testing-guide.md Normal file
View File

@@ -0,0 +1,430 @@
# Turnstile Testing Guide
**Complete testing strategies for E2E, unit, and integration tests**
**Official Docs**: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
---
## Quick Reference: Dummy Credentials
### Sitekeys (Client-Side)
```typescript
const TEST_SITEKEYS = {
ALWAYS_PASS: '1x00000000000000000000AA', // Visible, always passes
ALWAYS_BLOCK: '2x00000000000000000000AB', // Visible, always blocks
ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB', // Invisible, always passes
ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',// Invisible, always blocks
FORCE_INTERACTIVE: '3x00000000000000000000FF', // Visible, forces checkbox
}
```
### Secret Keys (Server-Side)
```typescript
const TEST_SECRET_KEYS = {
ALWAYS_PASS: '1x0000000000000000000000000000000AA', // success: true
ALWAYS_FAIL: '2x0000000000000000000000000000000AA', // success: false
TOKEN_SPENT: '3x0000000000000000000000000000000AA', // "already spent" error
}
```
### Dummy Token
```typescript
const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
```
**CRITICAL**:
- Dummy sitekeys generate `XXXX.DUMMY.TOKEN.XXXX`
- Dummy secret keys ONLY accept dummy token
- Production secret keys REJECT dummy token
- Real tokens FAIL with dummy secret keys
---
## Environment Detection Patterns
### Pattern 1: Request Headers
```typescript
function isTestEnvironment(request: Request): boolean {
return request.headers.get('x-test-environment') === 'true'
}
// Usage in Cloudflare Worker
if (isTestEnvironment(request)) {
secretKey = TEST_SECRET_KEYS.ALWAYS_PASS
}
```
### Pattern 2: IP Address
```typescript
function isTestEnvironment(request: Request): boolean {
const ip = request.headers.get('CF-Connecting-IP') || ''
const testIPs = ['127.0.0.1', '::1', 'localhost']
return testIPs.includes(ip)
}
```
### Pattern 3: Query Parameter
```typescript
function isTestEnvironment(request: Request): boolean {
const url = new URL(request.url)
return url.searchParams.get('test') === 'true'
}
```
### Pattern 4: Environment Variable
```typescript
const sitekey = process.env.NODE_ENV === 'test'
? TEST_SITEKEYS.ALWAYS_PASS
: process.env.TURNSTILE_SITE_KEY
```
---
## Playwright Testing
### Setup
```typescript
// playwright.config.ts
export default defineConfig({
use: {
baseURL: 'http://localhost:5173',
extraHTTPHeaders: {
'x-test-environment': 'true', // Auto-use test credentials
},
},
})
```
### Basic Test
```typescript
// tests/contact-form.spec.ts
import { test, expect } from '@playwright/test'
test('submits contact form with Turnstile', async ({ page }) => {
await page.goto('/contact')
// Fill form
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('textarea[name="message"]', 'Test message')
// Turnstile auto-solves with dummy token in test mode
await page.click('button[type="submit"]')
// Verify success
await expect(page.locator('.success-message')).toBeVisible()
})
```
### Advanced: Multiple Scenarios
```typescript
test('handles Turnstile failure gracefully', async ({ page, context }) => {
// Override to use "always fail" sitekey
await context.route('**/api.js', route => {
const FAIL_SITEKEY = '2x00000000000000000000AB'
// Inject failing sitekey
route.continue()
})
await page.goto('/contact')
await page.fill('input[name="email"]', 'test@example.com')
await page.click('button[type="submit"]')
await expect(page.locator('.error-message')).toContainText('verification failed')
})
```
---
## Cypress Testing
### Setup
```typescript
// cypress.config.ts
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
setupNodeEvents(on, config) {
// Set test header on all requests
on('before:browser:launch', (browser, launchOptions) => {
launchOptions.args.push('--disable-web-security')
return launchOptions
})
},
},
})
```
### Test Example
```typescript
// cypress/e2e/turnstile.cy.ts
describe('Turnstile Form', () => {
beforeEach(() => {
cy.intercept('**/*', (req) => {
req.headers['x-test-environment'] = 'true'
})
})
it('submits form successfully', () => {
cy.visit('/contact')
cy.get('input[name="email"]').type('test@example.com')
cy.get('textarea[name="message"]').type('Test message')
// Turnstile auto-solves in test mode
cy.get('button[type="submit"]').click()
cy.contains('Success').should('be.visible')
})
})
```
---
## Jest / Vitest (React)
### Mock @marsidev/react-turnstile
```typescript
// jest.setup.ts or vitest.setup.ts
import React from 'react'
jest.mock('@marsidev/react-turnstile', () => ({
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
// Auto-solve with dummy token
React.useEffect(() => {
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
}, [onSuccess])
return <div data-testid="turnstile-mock" />
},
}))
```
### Component Test
```typescript
// ContactForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ContactForm } from './ContactForm'
test('submits form with Turnstile', async () => {
render(<ContactForm />)
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'test@example.com' },
})
fireEvent.change(screen.getByLabelText('Message'), {
target: { value: 'Test message' },
})
// Turnstile auto-solves (mocked)
const submitButton = screen.getByRole('button', { name: 'Submit' })
expect(submitButton).not.toBeDisabled()
fireEvent.click(submitButton)
await waitFor(() => {
expect(screen.getByText('Success')).toBeInTheDocument()
})
})
```
---
## Server-Side Testing (Vitest)
### Validate Dummy Token
```typescript
// server.test.ts
import { describe, it, expect } from 'vitest'
import { validateTurnstile } from './turnstile-server-validation'
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'
describe('Turnstile Validation', () => {
it('validates dummy token with test secret', async () => {
const result = await validateTurnstile(
DUMMY_TOKEN,
TEST_SECRET_KEYS.ALWAYS_PASS
)
expect(result.success).toBe(true)
})
it('rejects real token with test secret', async () => {
const realToken = 'real-production-token'
const result = await validateTurnstile(
realToken,
TEST_SECRET_KEYS.ALWAYS_PASS
)
expect(result.success).toBe(false)
})
it('handles always-fail secret key', async () => {
const result = await validateTurnstile(
DUMMY_TOKEN,
TEST_SECRET_KEYS.ALWAYS_FAIL
)
expect(result.success).toBe(false)
})
})
```
---
## Cloudflare Workers Testing (Miniflare)
### Setup
```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'miniflare',
environmentOptions: {
bindings: {
TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA',
TURNSTILE_SITE_KEY: '1x00000000000000000000AA',
},
},
},
})
```
### Worker Test
```typescript
// worker.test.ts
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
import { describe, it, expect } from 'vitest'
import worker from '../src/index'
import { DUMMY_TOKEN } from './turnstile-test-config'
describe('Worker with Turnstile', () => {
it('validates test token successfully', async () => {
const formData = new FormData()
formData.append('email', 'test@example.com')
formData.append('cf-turnstile-response', DUMMY_TOKEN)
const request = new Request('http://localhost/api/contact', {
method: 'POST',
body: formData,
headers: {
'x-test-environment': 'true',
},
})
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
await waitOnExecutionContext(ctx)
expect(response.status).toBe(200)
})
})
```
---
## CI/CD Configuration
### GitHub Actions
```yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run tests
env:
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
NODE_ENV: test
run: npm test
```
### GitLab CI
```yaml
# .gitlab-ci.yml
test:
image: node:20
script:
- npm ci
- npm test
variables:
TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
NODE_ENV: "test"
```
---
## Testing Best Practices
**Always use dummy keys** - Never use production credentials in tests
**Test both success and failure** - Use both pass and fail test keys
**Mock in unit tests** - Mock Turnstile component for fast unit tests
**E2E with real widget** - Use test sitekeys in E2E tests
**Separate environments** - Different config for test/dev/staging/prod
**Test expiration** - Verify token expiration handling
**Test error states** - Validate error callback behavior
**Never commit production keys** - Use environment variables
**Don't skip server validation tests** - Critical security component
**Don't test with production sitekeys** - Can trigger rate limits
**Don't hardcode test keys** - Use constants/config files
---
## Debugging Tests
### Enable Verbose Logging
```typescript
turnstile.render('#container', {
sitekey: TEST_SITEKEYS.ALWAYS_PASS,
callback: (token) => console.log('[TEST] Token:', token),
'error-callback': (error) => console.error('[TEST] Error:', error),
'expired-callback': () => console.warn('[TEST] Token expired'),
})
```
### Check Network Requests
```typescript
// Playwright
await page.route('**/siteverify', route => {
console.log('Siteverify called with:', route.request().postData())
route.continue()
})
```
### Verify Environment Detection
```typescript
test('uses test credentials in test env', async ({ page }) => {
const response = await page.evaluate(() => {
return document.querySelector('.cf-turnstile')?.getAttribute('data-sitekey')
})
expect(response).toBe('1x00000000000000000000AA')
})
```
---
**Last Updated**: 2025-10-22
**Test Framework Support**: Playwright, Cypress, Jest, Vitest, Miniflare

View File

@@ -0,0 +1,412 @@
# Turnstile Widget Configuration Reference
**Complete reference for all widget configuration options**
**Official Docs**: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations
---
## Widget Modes
### Managed (Recommended)
Functions like a Managed Challenge Page. Selects a challenge based on visitor signals.
- Shows interactive checkbox only if bot is suspected
- Best balance of security and UX
- Use for most production deployments
### Non-Interactive
Widget is displayed but visitor never needs to interact.
- No checkbox required
- Runs challenge in background
- Use for minimal user friction
### Invisible
Widget is completely hidden from visitor.
- No visual presence
- Challenge runs invisibly
- Use for seamless UX, API protection
---
## Configuration Parameters
### Core Parameters
#### `sitekey` (Required)
- **Type**: `string`
- **Description**: Your widget's public sitekey from Cloudflare Dashboard
- **Example**: `data-sitekey="YOUR_SITE_KEY"` or `{ sitekey: 'YOUR_SITE_KEY' }`
#### `action`
- **Type**: `string`
- **Max Length**: 32 characters
- **Valid Characters**: `a-z`, `A-Z`, `0-9`, `-`, `_`
- **Description**: Custom action name tracked in analytics
- **Example**: `action: 'login'`, `action: 'signup'`
#### `cdata`
- **Type**: `string`
- **Max Length**: 255 characters
- **Description**: Custom data passed through to server validation
- **Example**: `cdata: JSON.stringify({ userId: '123' })`
---
## Appearance
### `theme`
Controls widget color scheme.
- **`auto`** (default) - Matches system preference
- **`light`** - Light mode
- **`dark`** - Dark mode
**Example**:
```html
<div class="cf-turnstile" data-theme="dark"></div>
```
```typescript
{ theme: 'dark' }
```
### `appearance`
Controls when widget becomes visible.
- **`always`** (default) - Visible from page load
- **`execute`** - Visible only after challenge begins
- **`interaction-only`** - Visible only when user interaction required
**Note**: Only affects managed/non-interactive modes. Invisible widgets never show.
**Example**:
```html
<div class="cf-turnstile" data-appearance="interaction-only"></div>
```
```typescript
{ appearance: 'interaction-only' }
```
### `size`
Controls widget dimensions.
- **`normal`** (default) - 300px × 65px
- **`compact`** - 150px × 140px
- **`flexible`** - 100% width, adapts to container
**Example**:
```html
<div class="cf-turnstile" data-size="compact"></div>
```
```typescript
{ size: 'flexible' }
```
---
## Execution
### `execution`
Controls when challenge runs and token is generated.
- **`render`** (default) - Runs automatically after rendering
- **`execute`** - Runs only when `turnstile.execute()` is called
**Use Cases**:
- `render`: Standard forms, immediate protection
- `execute`: Multi-step forms, conditional verification, performance optimization
**Example**:
```typescript
const widgetId = turnstile.render('#container', {
sitekey: SITE_KEY,
execution: 'execute', // Manual trigger
})
// Later, when needed:
turnstile.execute(widgetId)
```
---
## Callbacks
### `callback`
Called when challenge succeeds.
**Signature**: `(token: string) => void`
**Example**:
```html
<div class="cf-turnstile" data-callback="onSuccess"></div>
<script>
function onSuccess(token) {
console.log('Token:', token)
}
</script>
```
```typescript
{
callback: (token) => {
console.log('Success:', token)
document.getElementById('submit-btn').disabled = false
}
}
```
### `error-callback`
Called when challenge fails or errors occur.
**Signature**: `(errorCode: string) => void`
**Example**:
```typescript
{
'error-callback': (error) => {
console.error('Turnstile error:', error)
showErrorMessage('Verification failed')
}
}
```
### `expired-callback`
Called when token expires (after 5 minutes).
**Signature**: `() => void`
**Example**:
```typescript
{
'expired-callback': () => {
console.warn('Token expired')
turnstile.reset(widgetId)
}
}
```
### `timeout-callback`
Called when interactive challenge times out (user didn't interact).
**Signature**: `() => void`
**Example**:
```typescript
{
'timeout-callback': () => {
console.warn('Challenge timed out')
turnstile.reset(widgetId)
}
}
```
---
## Retry Behavior
### `retry`
Controls automatic retry on errors.
- **`auto`** (default) - Automatically retries on transient errors
- **`never`** - No automatic retry, manual control via `turnstile.reset()`
**Example**:
```typescript
{
retry: 'never', // Manual control
'error-callback': (error) => {
if (shouldRetry(error)) {
turnstile.reset(widgetId)
}
}
}
```
### `retry-interval`
Milliseconds between automatic retries.
- **Default**: 8000ms (8 seconds)
- **Min**: 0
- **Max**: No limit
**Example**:
```typescript
{
retry: 'auto',
'retry-interval': 5000, // 5 seconds
}
```
---
## Complete Configuration Example
### Implicit Rendering (HTML)
```html
<div class="cf-turnstile"
data-sitekey="YOUR_SITE_KEY"
data-callback="onSuccess"
data-error-callback="onError"
data-expired-callback="onExpired"
data-timeout-callback="onTimeout"
data-theme="auto"
data-size="normal"
data-appearance="always"
data-retry="auto"
data-retry-interval="8000"
data-action="login"
data-cdata='{"userId":"123"}'>
</div>
```
### Explicit Rendering (TypeScript)
```typescript
const widgetId = turnstile.render('#container', {
sitekey: 'YOUR_SITE_KEY',
callback: (token) => console.log('Success:', token),
'error-callback': (error) => console.error('Error:', error),
'expired-callback': () => turnstile.reset(widgetId),
'timeout-callback': () => console.warn('Timeout'),
theme: 'auto',
size: 'normal',
execution: 'render',
appearance: 'always',
retry: 'auto',
'retry-interval': 8000,
action: 'login',
cdata: JSON.stringify({ userId: '123' }),
})
```
---
## API Methods
### `turnstile.render()`
Renders a widget programmatically.
**Signature**:
```typescript
render(
container: string | HTMLElement,
options: TurnstileOptions
): string // Returns widgetId
```
**Example**:
```typescript
const widgetId = turnstile.render('#my-container', {
sitekey: SITE_KEY,
callback: handleSuccess,
})
```
### `turnstile.reset()`
Resets widget to initial state, clears current token.
**Signature**: `reset(widgetId: string): void`
**Example**:
```typescript
turnstile.reset(widgetId)
```
### `turnstile.remove()`
Completely removes widget from DOM.
**Signature**: `remove(widgetId: string): void`
**Example**:
```typescript
turnstile.remove(widgetId)
```
### `turnstile.execute()`
Manually triggers challenge (execution: 'execute' mode only).
**Signature**: `execute(widgetId: string): void`
**Example**:
```typescript
document.querySelector('#submit').addEventListener('click', () => {
turnstile.execute(widgetId)
})
```
### `turnstile.getResponse()`
Gets current token value.
**Signature**: `getResponse(widgetId: string): string | undefined`
**Example**:
```typescript
const token = turnstile.getResponse(widgetId)
if (token) {
submitForm(token)
}
```
### `turnstile.isExpired()`
Checks if token has expired.
**Signature**: `isExpired(widgetId: string): boolean`
**Example**:
```typescript
if (turnstile.isExpired(widgetId)) {
turnstile.reset(widgetId)
}
```
---
## Migration from reCAPTCHA
Turnstile can be a drop-in replacement for reCAPTCHA v2.
### Compatibility Mode
Add `?compat=recaptcha` to script URL:
```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
```
**Features**:
- Implicit rendering for reCAPTCHA
- `g-recaptcha-response` input name
- Registers API as `grecaptcha`
**Example**:
```html
<!-- Old reCAPTCHA code -->
<div class="g-recaptcha" data-sitekey="OLD_RECAPTCHA_KEY"></div>
<!-- New Turnstile code (compatibility mode) -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
<div class="g-recaptcha" data-sitekey="NEW_TURNSTILE_KEY"></div>
```
**Note**: Change script URL and sitekey, everything else stays the same.
---
## Best Practices
**Always validate server-side** - Client widget is not sufficient
**Handle expiration** - Implement `expired-callback` to reset widget
**Handle errors** - Use `error-callback` for user-friendly messages
**Use actions** - Track different form types in analytics
**Test with dummy keys** - Use `1x00000000000000000000AA` for development
**Separate environments** - Different widgets for dev/staging/production
**Never proxy api.js** - Must load from Cloudflare CDN
**Never reuse tokens** - Each token is single-use
**Never expose secret key** - Keep in backend only
**Never skip expiration handling** - Tokens expire after 5 minutes
---
**Last Updated**: 2025-10-22
**API Version**: v0 (stable)

132
scripts/check-csp.sh Executable file
View File

@@ -0,0 +1,132 @@
#!/bin/bash
#
# Turnstile CSP Checker
#
# Verifies that Content Security Policy headers allow Turnstile to function
#
# Usage:
# ./check-csp.sh https://example.com
# ./check-csp.sh https://example.com/contact
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if URL is provided
if [ -z "$1" ]; then
echo -e "${RED}Error: Please provide a URL${NC}"
echo "Usage: $0 <url>"
echo "Example: $0 https://example.com"
exit 1
fi
URL="$1"
echo -e "${GREEN}Checking CSP for Turnstile compatibility...${NC}"
echo "URL: $URL"
echo ""
# Fetch headers
HEADERS=$(curl -sI "$URL")
# Extract CSP header
CSP=$(echo "$HEADERS" | grep -i "content-security-policy:" | sed 's/content-security-policy: //I')
if [ -z "$CSP" ]; then
echo -e "${YELLOW}No Content-Security-Policy header found${NC}"
echo "✅ Turnstile should work (no CSP restrictions)"
exit 0
fi
echo "CSP Header found:"
echo "$CSP"
echo ""
# Initialize pass/fail counters
PASS=0
FAIL=0
# Check script-src
echo -e "${GREEN}Checking script-src...${NC}"
if echo "$CSP" | grep -q "script-src"; then
if echo "$CSP" | grep -E "script-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
echo "✅ script-src allows https://challenges.cloudflare.com"
((PASS++))
elif echo "$CSP" | grep -E "script-src[^;]*\*" > /dev/null; then
echo "✅ script-src allows * (wildcard)"
((PASS++))
else
echo -e "${RED}❌ script-src does NOT allow https://challenges.cloudflare.com${NC}"
echo " Add: script-src https://challenges.cloudflare.com;"
((FAIL++))
fi
else
echo "⚠️ No script-src directive found (defaults may apply)"
fi
echo ""
# Check frame-src
echo -e "${GREEN}Checking frame-src...${NC}"
if echo "$CSP" | grep -q "frame-src"; then
if echo "$CSP" | grep -E "frame-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
echo "✅ frame-src allows https://challenges.cloudflare.com"
((PASS++))
elif echo "$CSP" | grep -E "frame-src[^;]*\*" > /dev/null; then
echo "✅ frame-src allows * (wildcard)"
((PASS++))
else
echo -e "${RED}❌ frame-src does NOT allow https://challenges.cloudflare.com${NC}"
echo " Add: frame-src https://challenges.cloudflare.com;"
((FAIL++))
fi
else
echo "⚠️ No frame-src directive found (defaults may apply)"
fi
echo ""
# Check connect-src
echo -e "${GREEN}Checking connect-src...${NC}"
if echo "$CSP" | grep -q "connect-src"; then
if echo "$CSP" | grep -E "connect-src[^;]*https://challenges.cloudflare.com" > /dev/null; then
echo "✅ connect-src allows https://challenges.cloudflare.com"
((PASS++))
elif echo "$CSP" | grep -E "connect-src[^;]*\*" > /dev/null; then
echo "✅ connect-src allows * (wildcard)"
((PASS++))
else
echo -e "${RED}❌ connect-src does NOT allow https://challenges.cloudflare.com${NC}"
echo " Add: connect-src https://challenges.cloudflare.com;"
((FAIL++))
fi
else
echo "⚠️ No connect-src directive found (defaults may apply)"
fi
echo ""
# Final verdict
echo "========================================"
if [ $FAIL -eq 0 ]; then
echo -e "${GREEN}✅ CSP is compatible with Turnstile!${NC}"
exit 0
else
echo -e "${RED}❌ CSP may block Turnstile${NC}"
echo ""
echo "Recommended CSP directives:"
echo ""
echo "<meta http-equiv=\"Content-Security-Policy\" content=\""
echo " script-src 'self' https://challenges.cloudflare.com;"
echo " frame-src 'self' https://challenges.cloudflare.com;"
echo " connect-src 'self' https://challenges.cloudflare.com;"
echo "\">"
echo ""
echo "Or in HTTP header:"
echo ""
echo "Content-Security-Policy: script-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com"
echo ""
exit 1
fi

15
scripts/example-script.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# [TODO: Script Name]
# [TODO: Brief description of what this script does]
# Example script structure - delete if not needed
set -e # Exit on error
# [TODO: Add your script logic here]
echo "Example script - replace or delete this file"
# Usage:
# ./scripts/example-script.sh [args]

View File

@@ -0,0 +1,248 @@
/**
* Turnstile + Hono Route Handlers
*
* Complete examples for integrating Turnstile validation
* with Hono API routes in Cloudflare Workers
*/
import { Hono } from 'hono'
import { validateTurnstile, type TurnstileResponse } from './turnstile-server-validation'
/**
* Environment Bindings
*/
type Bindings = {
TURNSTILE_SECRET_KEY: string
TURNSTILE_SITE_KEY: string
}
const app = new Hono<{ Bindings: Bindings }>()
/**
* Example 1: Simple Contact Form
*/
app.post('/api/contact', async (c) => {
try {
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 contact form
const email = body.get('email')?.toString()
const message = body.get('message')?.toString()
console.log('Contact form submitted:', { email, message })
// Your business logic here (send email, save to DB, etc.)
return c.json({ message: 'Contact form submitted successfully' })
} catch (error) {
console.error('Contact form error:', error)
return c.text('Internal server error', 500)
}
})
/**
* Example 2: Login with Turnstile
*/
app.post('/api/auth/login', async (c) => {
try {
const { username, password, 'cf-turnstile-response': token } = await c.req.json()
// Validate Turnstile token first
const result = await validateTurnstile(
token,
c.env.TURNSTILE_SECRET_KEY,
{
remoteip: c.req.header('CF-Connecting-IP'),
expectedAction: 'login',
expectedHostname: new URL(c.req.url).hostname,
}
)
if (!result.success) {
return c.json(
{
error: 'Invalid Turnstile token',
codes: result['error-codes'],
},
401
)
}
// Validate credentials (example - use proper auth in production)
if (!username || !password) {
return c.json({ error: 'Missing credentials' }, 400)
}
// Check credentials against database
// const user = await db.query('SELECT * FROM users WHERE username = ?', [username])
// Create session token
// const sessionToken = await createSession(user.id)
return c.json({
message: 'Login successful',
// token: sessionToken,
})
} catch (error) {
console.error('Login error:', error)
return c.json({ error: 'Login failed' }, 500)
}
})
/**
* Example 3: Signup with Turnstile + Rate Limiting
*/
app.post('/api/auth/signup', async (c) => {
try {
const { email, password, 'cf-turnstile-response': token } = await c.req.json()
// Validate Turnstile
const result = await validateTurnstile(
token,
c.env.TURNSTILE_SECRET_KEY,
{
remoteip: c.req.header('CF-Connecting-IP'),
expectedAction: 'signup',
}
)
if (!result.success) {
return c.json({ error: 'Bot detection failed' }, 401)
}
// Validate input
if (!email || !password) {
return c.json({ error: 'Missing required fields' }, 400)
}
// Check if user exists
// const existingUser = await db.query('SELECT id FROM users WHERE email = ?', [email])
// if (existingUser) {
// return c.json({ error: 'User already exists' }, 409)
// }
// Create user
// const hashedPassword = await hashPassword(password)
// await db.query('INSERT INTO users (email, password) VALUES (?, ?)', [email, hashedPassword])
return c.json({
message: 'Signup successful',
})
} catch (error) {
console.error('Signup error:', error)
return c.json({ error: 'Signup failed' }, 500)
}
})
/**
* Example 4: Middleware for Turnstile Validation
*/
async function turnstileMiddleware(c: any, next: () => Promise<void>) {
const contentType = c.req.header('Content-Type')
let token: string | null = null
// Get token from FormData or JSON
if (contentType?.includes('multipart/form-data') || contentType?.includes('application/x-www-form-urlencoded')) {
const formData = await c.req.formData()
token = formData.get('cf-turnstile-response')?.toString() || null
} else if (contentType?.includes('application/json')) {
const body = await c.req.json()
token = body['cf-turnstile-response'] || null
}
if (!token) {
return c.json({ error: 'Missing Turnstile token' }, 400)
}
// Validate token
const result = await validateTurnstile(
token,
c.env.TURNSTILE_SECRET_KEY,
{
remoteip: c.req.header('CF-Connecting-IP'),
}
)
if (!result.success) {
return c.json({
error: 'Turnstile validation failed',
codes: result['error-codes'],
}, 401)
}
// Store result in context for route handler
c.set('turnstileResult', result)
await next()
}
/**
* Example 5: Using Middleware
*/
app.post('/api/protected/action', turnstileMiddleware, async (c) => {
const turnstileResult = c.get('turnstileResult') as TurnstileResponse
console.log('Turnstile validated:', turnstileResult)
// Your protected action here
return c.json({ message: 'Action completed successfully' })
})
/**
* Example 6: Get Sitekey Endpoint (for frontend)
*/
app.get('/api/turnstile/sitekey', (c) => {
return c.json({
sitekey: c.env.TURNSTILE_SITE_KEY,
})
})
/**
* Example 7: Health Check (without Turnstile)
*/
app.get('/health', (c) => {
return c.json({ status: 'ok' })
})
/**
* Example 8: CORS for Turnstile
*/
import { cors } from 'hono/cors'
app.use('/api/*', cors({
origin: ['https://yourdomain.com', 'http://localhost:5173'],
allowMethods: ['POST', 'GET', 'OPTIONS'],
allowHeaders: ['Content-Type'],
credentials: true,
}))
/**
* Export
*/
export default app

View File

@@ -0,0 +1,353 @@
/**
* Turnstile React Component
*
* Uses @marsidev/react-turnstile (Cloudflare recommended)
* npm install @marsidev/react-turnstile
*/
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { useRef, useState } from 'react'
/**
* Basic Example: Contact Form with Turnstile
*/
export function ContactForm() {
const [token, setToken] = useState<string>()
const [error, setError] = useState<string>()
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!token) {
setError('Please complete the verification')
return
}
setIsSubmitting(true)
setError(undefined)
const formData = new FormData(e.currentTarget)
formData.append('cf-turnstile-response', token)
try {
const response = await fetch('/api/contact', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorText = await response.text()
setError(`Submission failed: ${errorText}`)
return
}
// Success
alert('Message sent successfully!')
e.currentTarget.reset()
setToken(undefined)
} catch (err) {
setError(`Network error: ${err.message}`)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
/>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
rows={5}
required
/>
</div>
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
onError={() => setError('Verification failed')}
onExpire={() => setToken(undefined)}
/>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={!token || isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
/**
* Advanced Example: With Ref for Manual Control
*/
export function AdvancedTurnstileForm() {
const turnstileRef = useRef<TurnstileInstance>(null)
const [token, setToken] = useState<string>()
function handleReset() {
// Reset the Turnstile widget
turnstileRef.current?.reset()
setToken(undefined)
}
function handleRemove() {
// Completely remove the widget
turnstileRef.current?.remove()
setToken(undefined)
}
function handleExecute() {
// Manually trigger challenge (execution: 'execute' mode only)
turnstileRef.current?.execute()
}
return (
<div>
<Turnstile
ref={turnstileRef}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
onError={(error) => console.error('Turnstile error:', error)}
options={{
theme: 'auto',
size: 'normal',
execution: 'render', // or 'execute' for manual trigger
action: 'login',
retry: 'auto',
}}
/>
<div>
<button onClick={handleReset}>Reset Widget</button>
<button onClick={handleRemove}>Remove Widget</button>
<button onClick={handleExecute}>Execute Challenge</button>
</div>
{token && <div>Token: {token}</div>}
</div>
)
}
/**
* Next.js App Router Example (Client Component)
*/
'use client'
export function LoginForm() {
const [token, setToken] = useState<string>()
const [formError, setFormError] = useState<string>()
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!token) {
setFormError('Please complete the challenge')
return
}
const formData = new FormData(e.currentTarget)
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: formData.get('username'),
password: formData.get('password'),
'cf-turnstile-response': token,
}),
})
if (!response.ok) {
const error = await response.json()
setFormError(error.message)
return
}
// Redirect on success
window.location.href = '/dashboard'
} catch (err) {
setFormError(`Login failed: ${err.message}`)
}
}
return (
<form onSubmit={handleLogin}>
<input
name="username"
type="text"
placeholder="Username"
required
/>
<input
name="password"
type="password"
placeholder="Password"
required
/>
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
onError={() => setFormError('Verification failed')}
onExpire={() => setToken(undefined)}
options={{
theme: 'auto',
action: 'login',
}}
/>
{formError && <div className="error">{formError}</div>}
<button type="submit" disabled={!token}>
Login
</button>
</form>
)
}
/**
* Testing Example: Mock for Jest
*
* Add to jest.setup.ts:
*/
/*
jest.mock('@marsidev/react-turnstile', () => ({
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
// Auto-solve with dummy token
React.useEffect(() => {
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
}, [])
return <div data-testid="turnstile-mock" />
},
}))
*/
/**
* Environment-Aware Sitekey
*
* Use dummy keys for development/testing
*/
export function useT turnstileSiteKey() {
// Development/Test: Use dummy sitekey
if (process.env.NODE_ENV !== 'production') {
return '1x00000000000000000000AA' // Always passes
}
// Production: Use real sitekey
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
}
/**
* Example with Environment-Aware Sitekey
*/
export function SmartTurnstileForm() {
const siteKey = useTurnstileSiteKey()
const [token, setToken] = useState<string>()
return (
<form>
{/* Form fields here */}
<Turnstile
siteKey={siteKey}
onSuccess={setToken}
onError={(error) => console.error(error)}
/>
<button type="submit" disabled={!token}>
Submit
</button>
</form>
)
}
/**
* Custom Hook: useTurnstile
*/
export function useTurnstile() {
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)
setError(undefined)
}
const TurnstileWidget = () => (
<Turnstile
ref={turnstileRef}
siteKey={useTurnstileSiteKey()}
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 of useTurnstile Hook
*/
export function FormWithHook() {
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
// Use token for submission
console.log('Token:', token)
// Reset after submission
reset()
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<TurnstileWidget />
{error && <div className="error">{error}</div>}
<button type="submit" disabled={!isReady}>
Submit
</button>
</form>
)
}

View File

@@ -0,0 +1,240 @@
/**
* Turnstile Server-Side Validation
*
* CRITICAL: Server-side validation is MANDATORY.
* Client-side widget alone does NOT provide security.
*
* Tokens:
* - Expire after 5 minutes (300 seconds)
* - Are single-use only
* - Can be forged by attackers (must validate on server)
*/
/**
* Siteverify API Response
*/
export interface TurnstileResponse {
success: boolean
challenge_ts?: string // ISO 8601 timestamp
hostname?: string // Hostname where challenge was solved
'error-codes'?: string[]
action?: string // Custom action if specified
cdata?: string // Custom data if specified
}
/**
* Validation Options
*/
export interface ValidationOptions {
remoteip?: string
idempotency_key?: string
expectedAction?: string
expectedHostname?: string
timeout?: number // milliseconds (default: 5000)
}
/**
* Validate Turnstile Token
*
* @param token - The token from cf-turnstile-response
* @param secretKey - Your Turnstile secret key (from environment variable)
* @param options - Optional validation parameters
* @returns Promise<TurnstileResponse>
*/
export async function validateTurnstile(
token: string,
secretKey: string,
options?: ValidationOptions
): Promise<TurnstileResponse> {
if (!token) {
return {
success: false,
'error-codes': ['missing-input-response'],
}
}
if (!secretKey) {
return {
success: false,
'error-codes': ['missing-input-secret'],
}
}
// Prepare request body
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)
}
// Set timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), options?.timeout || 5000)
try {
// Call Siteverify API
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
body: formData,
signal: controller.signal,
}
)
const result = await response.json<TurnstileResponse>()
// Additional validation checks
if (result.success) {
// Validate action if specified
if (options?.expectedAction && result.action !== options.expectedAction) {
return {
success: false,
'error-codes': ['action-mismatch'],
}
}
// Validate hostname if specified
if (options?.expectedHostname && result.hostname !== options.expectedHostname) {
return {
success: false,
'error-codes': ['hostname-mismatch'],
}
}
}
return result
} catch (error) {
if (error.name === 'AbortError') {
return {
success: false,
'error-codes': ['timeout'],
}
}
console.error('Turnstile validation error:', error)
return {
success: false,
'error-codes': ['internal-error'],
}
} finally {
clearTimeout(timeoutId)
}
}
/**
* Cloudflare Workers Example
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
}
try {
const formData = await request.formData()
const token = formData.get('cf-turnstile-response')
if (!token) {
return new Response('Missing Turnstile token', { status: 400 })
}
// Validate token
const result = await validateTurnstile(
token.toString(),
env.TURNSTILE_SECRET_KEY,
{
remoteip: request.headers.get('CF-Connecting-IP') || undefined,
expectedHostname: new URL(request.url).hostname,
}
)
if (!result.success) {
console.error('Turnstile validation failed:', result['error-codes'])
return new Response('Invalid Turnstile token', {
status: 401,
headers: {
'Content-Type': 'application/json',
},
})
}
// Token is valid - process the form
const email = formData.get('email')
const message = formData.get('message')
// Your business logic here
console.log('Form submitted:', { email, message })
return new Response('Success!', {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})
} catch (error) {
console.error('Request handling error:', error)
return new Response('Internal server error', { status: 500 })
}
},
}
/**
* Advanced Example: Validation with Retry Logic
*/
export async function validateWithRetry(
token: string,
secretKey: string,
options?: ValidationOptions,
maxRetries: number = 3
): Promise<TurnstileResponse> {
let lastError: TurnstileResponse | null = null
for (let attempt = 0; attempt < maxRetries; attempt++) {
const result = await validateTurnstile(token, secretKey, options)
if (result.success) {
return result
}
// Don't retry on permanent errors
const permanentErrors = [
'missing-input-secret',
'invalid-input-secret',
'missing-input-response',
'invalid-input-response',
'action-mismatch',
'hostname-mismatch',
]
if (
result['error-codes']?.some((code) => permanentErrors.includes(code))
) {
return result
}
// Retry on transient errors
lastError = result
if (attempt < maxRetries - 1) {
// Exponential backoff
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)))
}
}
return lastError || { success: false, 'error-codes': ['max-retries-exceeded'] }
}
/**
* Type Definitions for Cloudflare Workers
*/
export interface Env {
TURNSTILE_SECRET_KEY: string
TURNSTILE_SITE_KEY: string
// Add other environment variables here
}

View File

@@ -0,0 +1,311 @@
/**
* Turnstile Testing Configuration
*
* Use dummy sitekeys and secret keys for automated testing
* to avoid hitting rate limits and ensure predictable behavior.
*
* Official Docs: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
*/
/**
* Dummy Sitekeys (Client-Side)
*
* These can be used from any domain, including localhost.
* Production secret keys will reject dummy tokens.
*/
export const TEST_SITEKEYS = {
/** Always passes - visible widget */
ALWAYS_PASS: '1x00000000000000000000AA',
/** Always blocks - visible widget */
ALWAYS_BLOCK: '2x00000000000000000000AB',
/** Always passes - invisible widget */
ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB',
/** Always blocks - invisible widget */
ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',
/** Forces an interactive challenge - visible widget */
FORCE_INTERACTIVE: '3x00000000000000000000FF',
} as const
/**
* Dummy Secret Keys (Server-Side)
*
* These only accept the dummy token XXXX.DUMMY.TOKEN.XXXX
* Real tokens will fail validation with these keys.
*/
export const TEST_SECRET_KEYS = {
/** Always returns success: true */
ALWAYS_PASS: '1x0000000000000000000000000000000AA',
/** Always returns success: false */
ALWAYS_FAIL: '2x0000000000000000000000000000000AA',
/** Returns "token already spent" error */
TOKEN_SPENT: '3x0000000000000000000000000000000AA',
} as const
/**
* Dummy Token
*
* Generated by test sitekeys.
* Only valid with test secret keys.
*/
export const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
/**
* Environment Detection
*
* Helper to determine if we're in a test environment
*/
export function isTestEnvironment(request: Request): boolean {
// Check for test headers
if (request.headers.get('x-test-environment') === 'true') {
return true
}
// Check for localhost/test IPs
const ip = request.headers.get('CF-Connecting-IP') || ''
const testIPs = ['127.0.0.1', '::1', 'localhost']
if (testIPs.includes(ip)) {
return true
}
// Check for test query parameter
const url = new URL(request.url)
if (url.searchParams.get('test') === 'true') {
return true
}
return false
}
/**
* Get Test Credentials
*
* Returns test or production credentials based on environment
*/
export function getTurnstileCredentials(
request: Request,
env: {
TURNSTILE_SITE_KEY?: string
TURNSTILE_SECRET_KEY?: string
}
) {
if (isTestEnvironment(request)) {
return {
sitekey: TEST_SITEKEYS.ALWAYS_PASS,
secretKey: TEST_SECRET_KEYS.ALWAYS_PASS,
}
}
return {
sitekey: env.TURNSTILE_SITE_KEY || '',
secretKey: env.TURNSTILE_SECRET_KEY || '',
}
}
/**
* Playwright Test Example
*/
/*
// playwright.config.ts
export default {
use: {
baseURL: 'http://localhost:5173',
extraHTTPHeaders: {
'x-test-environment': 'true',
},
},
}
// test/turnstile.spec.ts
import { test, expect } from '@playwright/test'
test('form submission with Turnstile', async ({ page }) => {
await page.goto('/contact')
// Fill form
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('textarea[name="message"]', 'Test message')
// Turnstile auto-solves with dummy token in test mode
await page.click('button[type="submit"]')
// Verify success
await expect(page.locator('.success-message')).toBeVisible()
})
*/
/**
* Cypress Test Example
*/
/*
// cypress/e2e/turnstile.cy.ts
describe('Turnstile Form', () => {
beforeEach(() => {
// Set test header
cy.intercept('**', (req) => {
req.headers['x-test-environment'] = 'true'
})
})
it('submits form successfully', () => {
cy.visit('/contact')
cy.get('input[name="email"]').type('test@example.com')
cy.get('textarea[name="message"]').type('Test message')
// Turnstile auto-solves in test mode
cy.get('button[type="submit"]').click()
cy.contains('Success').should('be.visible')
})
})
*/
/**
* Jest Mock Example
*/
/*
// jest.setup.ts
jest.mock('@marsidev/react-turnstile', () => ({
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
// Auto-solve with dummy token
React.useEffect(() => {
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
}, [onSuccess])
return <div data-testid="turnstile-mock" />
},
}))
// component.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { ContactForm } from './ContactForm'
test('submits form with Turnstile', async () => {
render(<ContactForm />)
fireEvent.change(screen.getByLabelText('Email'), {
target: { value: 'test@example.com' },
})
// Turnstile auto-solves
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
expect(await screen.findByText('Success')).toBeInTheDocument()
})
*/
/**
* Server-Side Test Example (Vitest)
*/
/*
import { describe, it, expect, vi } from 'vitest'
import { validateTurnstile } from './turnstile-server-validation'
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'
describe('Turnstile Validation', () => {
it('validates dummy token with test secret', async () => {
const result = await validateTurnstile(
DUMMY_TOKEN,
TEST_SECRET_KEYS.ALWAYS_PASS
)
expect(result.success).toBe(true)
})
it('rejects real token with test secret', async () => {
const realToken = 'some-real-token-from-production'
const result = await validateTurnstile(
realToken,
TEST_SECRET_KEYS.ALWAYS_PASS
)
expect(result.success).toBe(false)
})
})
*/
/**
* Environment-Aware Component (React)
*/
/*
import { Turnstile } from '@marsidev/react-turnstile'
import { TEST_SITEKEYS } from './turnstile-test-config'
function MyForm() {
const sitekey = process.env.NODE_ENV === 'test'
? TEST_SITEKEYS.ALWAYS_PASS
: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
return (
<form>
<Turnstile
siteKey={sitekey}
onSuccess={(token) => console.log('Token:', token)}
/>
</form>
)
}
*/
/**
* Cloudflare Workers Test Example
*/
/*
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
import { describe, it, expect } from 'vitest'
import worker from '../src/index'
import { DUMMY_TOKEN } from './turnstile-test-config'
describe('Worker with Turnstile', () => {
it('validates test token', async () => {
const formData = new FormData()
formData.append('email', 'test@example.com')
formData.append('cf-turnstile-response', DUMMY_TOKEN)
const request = new Request('http://localhost/api/contact', {
method: 'POST',
body: formData,
headers: {
'x-test-environment': 'true',
},
})
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
await waitOnExecutionContext(ctx)
expect(response.status).toBe(200)
})
})
*/
/**
* CI/CD Environment Variables
*
* Set these in your CI/CD pipeline:
*/
/*
# GitHub Actions
env:
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
# GitLab CI
variables:
TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
*/
export default {
TEST_SITEKEYS,
TEST_SECRET_KEYS,
DUMMY_TOKEN,
isTestEnvironment,
getTurnstileCredentials,
}

View File

@@ -0,0 +1,262 @@
/**
* Turnstile Widget - Explicit Rendering
*
* Use explicit rendering when you need programmatic control over:
* - When the widget renders
* - Widget lifecycle (reset, remove)
* - Multiple widgets on the same page
* - Dynamic UI / Single Page Applications
*/
declare const turnstile: {
render: (container: string | HTMLElement, options: TurnstileOptions) => string
reset: (widgetId: string) => void
remove: (widgetId: string) => void
execute: (widgetId: string) => void
getResponse: (widgetId: string) => string | undefined
isExpired: (widgetId: string) => boolean
}
interface TurnstileOptions {
sitekey: string
callback?: (token: string) => void
'error-callback'?: (error: string) => void
'expired-callback'?: () => void
'timeout-callback'?: () => void
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'flexible' | 'compact'
execution?: 'render' | 'execute'
appearance?: 'always' | 'execute' | 'interaction-only'
retry?: 'auto' | 'never'
'retry-interval'?: number
action?: string
cdata?: string
}
/**
* TurnstileManager - Lifecycle management wrapper
*/
export class TurnstileManager {
private widgetId: string | null = null
private sitekey: string
constructor(sitekey: string) {
this.sitekey = sitekey
}
/**
* Render the Turnstile widget
*/
render(
containerId: string,
callbacks: {
onSuccess: (token: string) => void
onError: (error: string) => void
onExpired?: () => void
},
options?: Partial<TurnstileOptions>
): string {
// Reset if already rendered
if (this.widgetId !== null) {
this.reset()
}
this.widgetId = turnstile.render(containerId, {
sitekey: this.sitekey,
callback: callbacks.onSuccess,
'error-callback': callbacks.onError,
'expired-callback': callbacks.onExpired || (() => this.reset()),
theme: options?.theme || 'auto',
size: options?.size || 'normal',
execution: options?.execution || 'render',
appearance: options?.appearance || 'always',
retry: options?.retry || 'auto',
action: options?.action,
cdata: options?.cdata,
})
return this.widgetId
}
/**
* Reset the widget (clears current state)
*/
reset(): void {
if (this.widgetId !== null) {
turnstile.reset(this.widgetId)
}
}
/**
* Remove the widget completely
*/
remove(): void {
if (this.widgetId !== null) {
turnstile.remove(this.widgetId)
this.widgetId = null
}
}
/**
* Manually trigger challenge (execution: 'execute' mode only)
*/
execute(): void {
if (this.widgetId !== null) {
turnstile.execute(this.widgetId)
}
}
/**
* Get current token
*/
getToken(): string | undefined {
if (this.widgetId === null) return undefined
return turnstile.getResponse(this.widgetId)
}
/**
* Check if token is expired
*/
isExpired(): boolean {
if (this.widgetId === null) return true
return turnstile.isExpired(this.widgetId)
}
}
/**
* Usage Example
*/
export function initializeTurnstile() {
const SITE_KEY = 'YOUR_SITE_KEY' // Replace with actual sitekey
const manager = new TurnstileManager(SITE_KEY)
// Render widget when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
manager.render(
'#turnstile-container',
{
onSuccess: (token) => {
console.log('Turnstile success:', token)
// Enable submit button
const submitBtn = document.querySelector('#submit-btn') as HTMLButtonElement
if (submitBtn) submitBtn.disabled = false
},
onError: (error) => {
console.error('Turnstile error:', error)
// Show error message
const errorDiv = document.querySelector('#error-message')
if (errorDiv) {
errorDiv.textContent = 'Verification failed. Please try again.'
}
},
onExpired: () => {
console.warn('Turnstile token expired')
// Disable submit button
const submitBtn = document.querySelector('#submit-btn') as HTMLButtonElement
if (submitBtn) submitBtn.disabled = true
},
},
{
theme: 'auto',
size: 'normal',
action: 'login', // Optional: track action in analytics
}
)
})
// Example: Reset on form submission
const form = document.querySelector('#myForm') as HTMLFormElement
form?.addEventListener('submit', async (e) => {
e.preventDefault()
const token = manager.getToken()
if (!token || manager.isExpired()) {
alert('Please complete the verification')
return
}
// Submit form with token
const formData = new FormData(form)
formData.append('cf-turnstile-response', token)
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: formData,
})
if (response.ok) {
alert('Success!')
form.reset()
manager.reset() // Reset Turnstile for next submission
} else {
alert('Submission failed')
}
} catch (error) {
console.error('Submission error:', error)
alert('Network error')
}
})
}
/**
* Advanced Example: Manual Execution Mode
*/
export function manualExecutionExample() {
const manager = new TurnstileManager('YOUR_SITE_KEY')
// Render in manual execution mode
manager.render(
'#turnstile-container',
{
onSuccess: (token) => {
console.log('Challenge complete:', token)
},
onError: (error) => {
console.error('Challenge failed:', error)
},
},
{
execution: 'execute', // Manual trigger
appearance: 'interaction-only', // Show only when needed
}
)
// Trigger challenge when user clicks submit
document.querySelector('#submit-btn')?.addEventListener('click', () => {
manager.execute()
})
}
/**
* Advanced Example: Multiple Widgets
*/
export function multipleWidgetsExample() {
const loginManager = new TurnstileManager('YOUR_SITE_KEY')
const signupManager = new TurnstileManager('YOUR_SITE_KEY')
// Login widget
loginManager.render(
'#login-turnstile',
{
onSuccess: (token) => console.log('Login token:', token),
onError: (error) => console.error('Login error:', error),
},
{
action: 'login',
}
)
// Signup widget
signupManager.render(
'#signup-turnstile',
{
onSuccess: (token) => console.log('Signup token:', token),
onError: (error) => console.error('Signup error:', error),
},
{
action: 'signup',
}
)
}

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Turnstile Example - Implicit Rendering</title>
<!-- CRITICAL: Load from Cloudflare CDN only - never proxy or cache -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<!-- Optional: CSP headers if needed -->
<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;
">
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 600px;
margin: 40px auto;
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
input, textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
background: #0070f3;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background: #0051cc;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.error {
color: red;
margin-top: 10px;
}
.success {
color: green;
margin-top: 10px;
}
</style>
</head>
<body>
<h1>Contact Form with Turnstile</h1>
<form id="contactForm" action="/api/contact" method="POST">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<!-- Turnstile Widget - Implicit Rendering -->
<div class="cf-turnstile"
data-sitekey="YOUR_SITE_KEY"
data-callback="onTurnstileSuccess"
data-error-callback="onTurnstileError"
data-expired-callback="onTurnstileExpired"
data-theme="auto"
data-size="normal"></div>
<button type="submit" id="submitButton">Submit</button>
<div id="message"></div>
</form>
<script>
let turnstileToken = null;
function onTurnstileSuccess(token) {
console.log('Turnstile success:', token);
turnstileToken = token;
document.getElementById('submitButton').disabled = false;
}
function onTurnstileError(error) {
console.error('Turnstile error:', error);
showMessage('Verification failed. Please try again.', 'error');
document.getElementById('submitButton').disabled = true;
}
function onTurnstileExpired() {
console.warn('Turnstile token expired');
turnstileToken = null;
document.getElementById('submitButton').disabled = true;
showMessage('Verification expired. Please try again.', 'error');
}
function showMessage(text, type) {
const messageDiv = document.getElementById('message');
messageDiv.textContent = text;
messageDiv.className = type;
}
// Handle form submission
document.getElementById('contactForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!turnstileToken) {
showMessage('Please complete the verification.', 'error');
return;
}
const formData = new FormData(e.target);
// Token is automatically added as 'cf-turnstile-response' by Turnstile
try {
const response = await fetch('/api/contact', {
method: 'POST',
body: formData,
});
if (response.ok) {
showMessage('Message sent successfully!', 'success');
e.target.reset();
turnstileToken = null;
// Reset Turnstile widget
turnstile.reset();
} else {
const error = await response.text();
showMessage(`Error: ${error}`, 'error');
}
} catch (error) {
showMessage(`Network error: ${error.message}`, 'error');
}
});
// Disable submit button initially
document.getElementById('submitButton').disabled = true;
</script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
{
"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"
}
}
}
}