mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-03-21 18:11:27 -07:00
update skills
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# Cloudflare Turnstile Implementation Skill Reference
|
||||
|
||||
Expert guidance for implementing Cloudflare Turnstile - a smart CAPTCHA alternative that protects websites from bots without showing traditional CAPTCHA puzzles.
|
||||
|
||||
## Overview
|
||||
|
||||
Turnstile is a user-friendly CAPTCHA alternative that runs challenges in the background without user interaction. It validates visitors automatically using signals like browser behavior, device fingerprinting, and machine learning.
|
||||
|
||||
## Widget Types
|
||||
|
||||
| Type | Interaction | Use Case |
|
||||
|------|-------------|----------|
|
||||
| **Managed** (default) | Shows checkbox when needed | Forms, logins - balance UX and security |
|
||||
| **Non-Interactive** | Invisible, runs automatically | Frictionless UX, low-risk actions |
|
||||
| **Invisible** | Hidden, triggered programmatically | Pre-clearance, API calls, headless |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Implicit Rendering (HTML-based)
|
||||
```html
|
||||
<!-- 1. Add script -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
|
||||
<!-- 2. Add widget to form -->
|
||||
<form action="/submit" method="POST">
|
||||
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Explicit Rendering (JavaScript-based)
|
||||
```html
|
||||
<div id="turnstile-container"></div>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
<script>
|
||||
window.turnstile.render('#turnstile-container', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
callback: (token) => console.log('Token:', token)
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Server Validation (Required)
|
||||
```javascript
|
||||
// Cloudflare Workers
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const formData = await request.formData();
|
||||
const token = formData.get('cf-turnstile-response');
|
||||
|
||||
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
secret: env.TURNSTILE_SECRET,
|
||||
response: token,
|
||||
remoteip: request.headers.get('CF-Connecting-IP')
|
||||
})
|
||||
});
|
||||
|
||||
const validation = await result.json();
|
||||
if (!validation.success) {
|
||||
return new Response('Invalid CAPTCHA', { status: 400 });
|
||||
}
|
||||
// Process form...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Keys
|
||||
|
||||
**Critical for development/testing:**
|
||||
|
||||
| Type | Key | Behavior |
|
||||
|------|-----|----------|
|
||||
| **Site Key (Always Passes)** | `1x00000000000000000000AA` | Widget succeeds, token validates |
|
||||
| **Site Key (Always Blocks)** | `2x00000000000000000000AB` | Widget fails visibly |
|
||||
| **Site Key (Force Challenge)** | `3x00000000000000000000FF` | Always shows interactive challenge |
|
||||
| **Secret Key (Testing)** | `1x0000000000000000000000000000000AA` | Validates test tokens |
|
||||
|
||||
**Note:** Test keys work on `localhost` and any domain. Do NOT use in production.
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- **Token expiry:** 5 minutes after generation
|
||||
- **Single-use:** Each token can only be validated once
|
||||
- **Server validation required:** Client-side checks are insufficient
|
||||
|
||||
## Reading Order
|
||||
|
||||
1. **[configuration.md](configuration.md)** - Setup, widget options, script loading
|
||||
2. **[api.md](api.md)** - JavaScript API, siteverify endpoints, TypeScript types
|
||||
3. **[patterns.md](patterns.md)** - Form integration, framework examples, validation patterns
|
||||
4. **[gotchas.md](gotchas.md)** - Common errors, debugging, limitations
|
||||
|
||||
## See Also
|
||||
|
||||
- [Cloudflare Turnstile Docs](https://developers.cloudflare.com/turnstile/)
|
||||
- [Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile)
|
||||
240
.agents/skills/cloudflare-deploy/references/turnstile/api.md
Normal file
240
.agents/skills/cloudflare-deploy/references/turnstile/api.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# API Reference
|
||||
|
||||
## Client-Side JavaScript API
|
||||
|
||||
The Turnstile JavaScript API is available at `window.turnstile` after loading the script.
|
||||
|
||||
### `turnstile.render(container, options)`
|
||||
|
||||
Renders a Turnstile widget into a container element.
|
||||
|
||||
**Parameters:**
|
||||
- `container` (string | HTMLElement): CSS selector or DOM element
|
||||
- `options` (TurnstileOptions): Configuration object (see [configuration.md](configuration.md))
|
||||
|
||||
**Returns:** `string` - Widget ID for use with other API methods
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
const widgetId = window.turnstile.render('#my-container', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
callback: (token) => console.log('Success:', token),
|
||||
'error-callback': (code) => console.error('Error:', code)
|
||||
});
|
||||
```
|
||||
|
||||
### `turnstile.reset(widgetId)`
|
||||
|
||||
Resets a widget (clears token, resets challenge state). Useful when form validation fails.
|
||||
|
||||
**Parameters:**
|
||||
- `widgetId` (string): Widget ID from `render()`, or container element
|
||||
|
||||
**Returns:** `void`
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// Reset on form error
|
||||
if (!validateForm()) {
|
||||
window.turnstile.reset(widgetId);
|
||||
}
|
||||
```
|
||||
|
||||
### `turnstile.remove(widgetId)`
|
||||
|
||||
Removes a widget from the DOM completely.
|
||||
|
||||
**Parameters:**
|
||||
- `widgetId` (string): Widget ID from `render()`
|
||||
|
||||
**Returns:** `void`
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// Cleanup on navigation
|
||||
window.turnstile.remove(widgetId);
|
||||
```
|
||||
|
||||
### `turnstile.getResponse(widgetId)`
|
||||
|
||||
Gets the current token from a widget (if challenge completed).
|
||||
|
||||
**Parameters:**
|
||||
- `widgetId` (string): Widget ID from `render()`, or container element
|
||||
|
||||
**Returns:** `string | undefined` - Token string, or undefined if not ready
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
const token = window.turnstile.getResponse(widgetId);
|
||||
if (token) {
|
||||
submitForm(token);
|
||||
}
|
||||
```
|
||||
|
||||
### `turnstile.isExpired(widgetId)`
|
||||
|
||||
Checks if a widget's token has expired (>5 minutes old).
|
||||
|
||||
**Parameters:**
|
||||
- `widgetId` (string): Widget ID from `render()`
|
||||
|
||||
**Returns:** `boolean` - True if expired
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
if (window.turnstile.isExpired(widgetId)) {
|
||||
window.turnstile.reset(widgetId);
|
||||
}
|
||||
```
|
||||
|
||||
## Callback Signatures
|
||||
|
||||
```typescript
|
||||
type TurnstileCallback = (token: string) => void;
|
||||
type ErrorCallback = (errorCode: string) => void;
|
||||
type TimeoutCallback = () => void;
|
||||
type ExpiredCallback = () => void;
|
||||
type BeforeInteractiveCallback = () => void;
|
||||
type AfterInteractiveCallback = () => void;
|
||||
type UnsupportedCallback = () => void;
|
||||
```
|
||||
|
||||
## Siteverify API (Server-Side)
|
||||
|
||||
**Endpoint:** `https://challenges.cloudflare.com/turnstile/v0/siteverify`
|
||||
|
||||
### Request
|
||||
|
||||
**Method:** POST
|
||||
**Content-Type:** `application/json` or `application/x-www-form-urlencoded`
|
||||
|
||||
```typescript
|
||||
interface SiteverifyRequest {
|
||||
secret: string; // Your secret key (never expose client-side)
|
||||
response: string; // Token from cf-turnstile-response
|
||||
remoteip?: string; // User's IP (optional but recommended)
|
||||
idempotency_key?: string; // Unique key for idempotent validation
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// Cloudflare Workers
|
||||
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
secret: env.TURNSTILE_SECRET,
|
||||
response: token,
|
||||
remoteip: request.headers.get('CF-Connecting-IP')
|
||||
})
|
||||
});
|
||||
const data = await result.json();
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```typescript
|
||||
interface SiteverifyResponse {
|
||||
success: boolean; // Validation result
|
||||
challenge_ts?: string; // ISO timestamp of challenge
|
||||
hostname?: string; // Hostname where widget was solved
|
||||
'error-codes'?: string[]; // Error codes if success=false
|
||||
action?: string; // Action name from widget config
|
||||
cdata?: string; // Custom data from widget config
|
||||
}
|
||||
```
|
||||
|
||||
**Example Success:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"challenge_ts": "2024-01-15T10:30:00Z",
|
||||
"hostname": "example.com",
|
||||
"action": "login",
|
||||
"cdata": "user123"
|
||||
}
|
||||
```
|
||||
|
||||
**Example Failure:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error-codes": ["timeout-or-duplicate"]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Cause | Solution |
|
||||
|------|-------|----------|
|
||||
| `missing-input-secret` | Secret key not provided | Include `secret` in request |
|
||||
| `invalid-input-secret` | Secret key is wrong | Check secret key in dashboard |
|
||||
| `missing-input-response` | Token not provided | Include `response` token |
|
||||
| `invalid-input-response` | Token is invalid/malformed | Verify token from widget |
|
||||
| `timeout-or-duplicate` | Token expired (>5min) or reused | Generate new token, validate once |
|
||||
| `internal-error` | Cloudflare server error | Retry with exponential backoff |
|
||||
| `bad-request` | Malformed request | Check JSON/form encoding |
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
```typescript
|
||||
interface TurnstileOptions {
|
||||
sitekey: string;
|
||||
action?: string;
|
||||
cData?: string;
|
||||
callback?: (token: string) => void;
|
||||
'error-callback'?: (errorCode: string) => void;
|
||||
'expired-callback'?: () => void;
|
||||
'timeout-callback'?: () => void;
|
||||
'before-interactive-callback'?: () => void;
|
||||
'after-interactive-callback'?: () => void;
|
||||
'unsupported-callback'?: () => void;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible';
|
||||
tabindex?: number;
|
||||
'response-field'?: boolean;
|
||||
'response-field-name'?: string;
|
||||
retry?: 'auto' | 'never';
|
||||
'retry-interval'?: number;
|
||||
language?: string;
|
||||
execution?: 'render' | 'execute';
|
||||
appearance?: 'always' | 'execute' | 'interaction-only';
|
||||
'refresh-expired'?: 'auto' | 'manual' | 'never';
|
||||
}
|
||||
|
||||
interface Turnstile {
|
||||
render(container: string | HTMLElement, options: TurnstileOptions): string;
|
||||
reset(widgetId: string): void;
|
||||
remove(widgetId: string): void;
|
||||
getResponse(widgetId: string): string | undefined;
|
||||
isExpired(widgetId: string): boolean;
|
||||
execute(container?: string | HTMLElement, options?: TurnstileOptions): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile: Turnstile;
|
||||
onloadTurnstileCallback?: () => void;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Script Loading
|
||||
|
||||
```html
|
||||
<!-- Standard -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
|
||||
<!-- Explicit render mode -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
|
||||
<!-- With load callback -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback"></script>
|
||||
<script>
|
||||
window.onloadTurnstileCallback = () => {
|
||||
window.turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY' });
|
||||
};
|
||||
</script>
|
||||
```
|
||||
@@ -0,0 +1,222 @@
|
||||
# Configuration
|
||||
|
||||
## Script Loading
|
||||
|
||||
### Basic (Implicit Rendering)
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
```
|
||||
Automatically renders widgets with `class="cf-turnstile"` on page load.
|
||||
|
||||
### Explicit Rendering
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
```
|
||||
Manual control over when/where widgets render via `window.turnstile.render()`.
|
||||
|
||||
### With Load Callback
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=myCallback"></script>
|
||||
<script>
|
||||
function myCallback() {
|
||||
// API ready
|
||||
window.turnstile.render('#container', { sitekey: 'YOUR_SITE_KEY' });
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Compatibility Mode
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
|
||||
```
|
||||
Provides `grecaptcha` API for Google reCAPTCHA drop-in replacement.
|
||||
|
||||
## Widget Configuration
|
||||
|
||||
### Complete Options Object
|
||||
|
||||
```javascript
|
||||
{
|
||||
// Required
|
||||
sitekey: 'YOUR_SITE_KEY', // Widget sitekey from dashboard
|
||||
|
||||
// Callbacks
|
||||
callback: (token) => {}, // Success - token ready
|
||||
'error-callback': (code) => {}, // Error occurred
|
||||
'expired-callback': () => {}, // Token expired (>5min)
|
||||
'timeout-callback': () => {}, // Challenge timeout
|
||||
'before-interactive-callback': () => {}, // Before showing checkbox
|
||||
'after-interactive-callback': () => {}, // After user interacts
|
||||
'unsupported-callback': () => {}, // Browser doesn't support Turnstile
|
||||
|
||||
// Appearance
|
||||
theme: 'auto', // 'light' | 'dark' | 'auto'
|
||||
size: 'normal', // 'normal' | 'compact' | 'flexible'
|
||||
tabindex: 0, // Tab order (accessibility)
|
||||
language: 'auto', // ISO 639-1 code or 'auto'
|
||||
|
||||
// Behavior
|
||||
execution: 'render', // 'render' (auto) | 'execute' (manual)
|
||||
appearance: 'always', // 'always' | 'execute' | 'interaction-only'
|
||||
retry: 'auto', // 'auto' | 'never'
|
||||
'retry-interval': 8000, // Retry interval (ms), default 8000
|
||||
'refresh-expired': 'auto', // 'auto' | 'manual' | 'never'
|
||||
|
||||
// Form Integration
|
||||
'response-field': true, // Add hidden input (default: true)
|
||||
'response-field-name': 'cf-turnstile-response', // Hidden input name
|
||||
|
||||
// Analytics & Data
|
||||
action: 'login', // Action name (for analytics)
|
||||
cData: 'user-session-123', // Custom data (returned in siteverify)
|
||||
}
|
||||
```
|
||||
|
||||
### Key Options Explained
|
||||
|
||||
**`execution`:**
|
||||
- `'render'` (default): Challenge starts immediately on render
|
||||
- `'execute'`: Wait for `turnstile.execute()` call
|
||||
|
||||
**`appearance`:**
|
||||
- `'always'` (default): Widget always visible
|
||||
- `'execute'`: Hidden until `execute()` called
|
||||
- `'interaction-only'`: Hidden until user interaction needed
|
||||
|
||||
**`refresh-expired`:**
|
||||
- `'auto'` (default): Auto-refresh expired tokens
|
||||
- `'manual'`: App must call `reset()` after expiry
|
||||
- `'never'`: No refresh, expired-callback triggered
|
||||
|
||||
**`retry`:**
|
||||
- `'auto'` (default): Auto-retry failed challenges
|
||||
- `'never'`: Don't retry, trigger error-callback
|
||||
|
||||
## HTML Data Attributes
|
||||
|
||||
For implicit rendering, use data attributes on `<div class="cf-turnstile">`:
|
||||
|
||||
| JavaScript Property | HTML Data Attribute | Example |
|
||||
|---------------------|---------------------|---------|
|
||||
| `sitekey` | `data-sitekey` | `data-sitekey="YOUR_KEY"` |
|
||||
| `action` | `data-action` | `data-action="login"` |
|
||||
| `cData` | `data-cdata` | `data-cdata="session-123"` |
|
||||
| `callback` | `data-callback` | `data-callback="onSuccess"` |
|
||||
| `error-callback` | `data-error-callback` | `data-error-callback="onError"` |
|
||||
| `expired-callback` | `data-expired-callback` | `data-expired-callback="onExpired"` |
|
||||
| `timeout-callback` | `data-timeout-callback` | `data-timeout-callback="onTimeout"` |
|
||||
| `theme` | `data-theme` | `data-theme="dark"` |
|
||||
| `size` | `data-size` | `data-size="compact"` |
|
||||
| `tabindex` | `data-tabindex` | `data-tabindex="0"` |
|
||||
| `response-field` | `data-response-field` | `data-response-field="false"` |
|
||||
| `response-field-name` | `data-response-field-name` | `data-response-field-name="token"` |
|
||||
| `retry` | `data-retry` | `data-retry="never"` |
|
||||
| `retry-interval` | `data-retry-interval` | `data-retry-interval="5000"` |
|
||||
| `language` | `data-language` | `data-language="en"` |
|
||||
| `execution` | `data-execution` | `data-execution="execute"` |
|
||||
| `appearance` | `data-appearance` | `data-appearance="interaction-only"` |
|
||||
| `refresh-expired` | `data-refresh-expired` | `data-refresh-expired="manual"` |
|
||||
|
||||
**Example:**
|
||||
```html
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="YOUR_SITE_KEY"
|
||||
data-theme="dark"
|
||||
data-callback="onTurnstileSuccess"
|
||||
data-error-callback="onTurnstileError"></div>
|
||||
```
|
||||
|
||||
## Content Security Policy
|
||||
|
||||
Add these directives to CSP header/meta tag:
|
||||
|
||||
```
|
||||
script-src https://challenges.cloudflare.com;
|
||||
frame-src https://challenges.cloudflare.com;
|
||||
```
|
||||
|
||||
**Full Example:**
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self';
|
||||
script-src 'self' https://challenges.cloudflare.com;
|
||||
frame-src https://challenges.cloudflare.com;">
|
||||
```
|
||||
|
||||
## Framework-Specific Setup
|
||||
|
||||
### React
|
||||
```bash
|
||||
npm install @marsidev/react-turnstile
|
||||
```
|
||||
```jsx
|
||||
import Turnstile from '@marsidev/react-turnstile';
|
||||
|
||||
<Turnstile
|
||||
siteKey="YOUR_SITE_KEY"
|
||||
onSuccess={(token) => console.log(token)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Vue
|
||||
```bash
|
||||
npm install vue-turnstile
|
||||
```
|
||||
```vue
|
||||
<template>
|
||||
<VueTurnstile site-key="YOUR_SITE_KEY" @success="onSuccess" />
|
||||
</template>
|
||||
<script setup>
|
||||
import VueTurnstile from 'vue-turnstile';
|
||||
</script>
|
||||
```
|
||||
|
||||
### Svelte
|
||||
```bash
|
||||
npm install svelte-turnstile
|
||||
```
|
||||
```svelte
|
||||
<script>
|
||||
import Turnstile from 'svelte-turnstile';
|
||||
</script>
|
||||
<Turnstile siteKey="YOUR_SITE_KEY" on:turnstile-callback={handleToken} />
|
||||
```
|
||||
|
||||
### Next.js (App Router)
|
||||
```tsx
|
||||
// app/components/TurnstileWidget.tsx
|
||||
'use client';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function TurnstileWidget({ sitekey, onSuccess }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && window.turnstile) {
|
||||
const widgetId = window.turnstile.render(ref.current, {
|
||||
sitekey,
|
||||
callback: onSuccess
|
||||
});
|
||||
return () => window.turnstile.remove(widgetId);
|
||||
}
|
||||
}, [sitekey, onSuccess]);
|
||||
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Cloudflare Pages Plugin
|
||||
|
||||
```bash
|
||||
npm install @cloudflare/pages-plugin-turnstile
|
||||
```
|
||||
|
||||
```typescript
|
||||
// functions/_middleware.ts
|
||||
import turnstilePlugin from '@cloudflare/pages-plugin-turnstile';
|
||||
|
||||
export const onRequest = turnstilePlugin({
|
||||
secret: 'YOUR_SECRET_KEY',
|
||||
onError: () => new Response('CAPTCHA failed', { status: 403 })
|
||||
});
|
||||
```
|
||||
218
.agents/skills/cloudflare-deploy/references/turnstile/gotchas.md
Normal file
218
.agents/skills/cloudflare-deploy/references/turnstile/gotchas.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Troubleshooting & Gotchas
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### ❌ Skipping Server-Side Validation
|
||||
**Problem:** Client-only validation is easily bypassed.
|
||||
|
||||
**Solution:** Always validate on server.
|
||||
```javascript
|
||||
// CORRECT - Server validates token
|
||||
app.post('/submit', async (req, res) => {
|
||||
const token = req.body['cf-turnstile-response'];
|
||||
const validation = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ secret: SECRET, response: token })
|
||||
}).then(r => r.json());
|
||||
|
||||
if (!validation.success) return res.status(403).json({ error: 'CAPTCHA failed' });
|
||||
});
|
||||
```
|
||||
|
||||
### ❌ Exposing Secret Key
|
||||
**Problem:** Secret key leaked in client-side code.
|
||||
|
||||
**Solution:** Server-side validation only. Never send secret to client.
|
||||
|
||||
### ❌ Reusing Tokens (Single-Use Rule)
|
||||
**Problem:** Tokens are single-use. Revalidation fails with `timeout-or-duplicate`.
|
||||
|
||||
**Solution:** Generate new token for each submission. Reset widget on error.
|
||||
```javascript
|
||||
if (!response.ok) window.turnstile.reset(widgetId);
|
||||
```
|
||||
|
||||
### ❌ Not Handling Token Expiry
|
||||
**Problem:** Tokens expire after 5 minutes.
|
||||
|
||||
**Solution:** Handle expiry callback or use auto-refresh.
|
||||
```javascript
|
||||
window.turnstile.render('#container', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
'refresh-expired': 'auto', // or 'manual' with expired-callback
|
||||
'expired-callback': () => window.turnstile.reset(widgetId)
|
||||
});
|
||||
```
|
||||
|
||||
## Common Errors
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| **Widget not rendering** | Incorrect sitekey, CSP blocking, file:// protocol | Check sitekey, add CSP for challenges.cloudflare.com, use http:// |
|
||||
| **timeout-or-duplicate** | Token expired (>5min) or reused | Generate fresh token, don't cache >5min |
|
||||
| **invalid-input-secret** | Wrong secret key | Verify secret from dashboard, check env vars |
|
||||
| **missing-input-response** | Token not sent | Check form field name is 'cf-turnstile-response' |
|
||||
|
||||
## Framework Gotchas
|
||||
|
||||
### React: Widget Re-mounting
|
||||
**Problem:** Widget re-renders on state change, losing token.
|
||||
|
||||
**Solution:** Control lifecycle with useRef.
|
||||
```tsx
|
||||
function TurnstileWidget({ onToken }) {
|
||||
const containerRef = useRef(null);
|
||||
const widgetIdRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && !widgetIdRef.current) {
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
callback: onToken
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (widgetIdRef.current) {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
```
|
||||
|
||||
### React StrictMode: Double Render
|
||||
**Problem:** Widget renders twice in dev due to StrictMode.
|
||||
|
||||
**Solution:** Use cleanup function.
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const widgetId = window.turnstile.render('#container', { sitekey });
|
||||
return () => window.turnstile.remove(widgetId);
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Next.js: SSR Hydration
|
||||
**Problem:** `window.turnstile` undefined during SSR.
|
||||
|
||||
**Solution:** Use `'use client'` or dynamic import with `ssr: false`.
|
||||
```tsx
|
||||
'use client';
|
||||
export default function Turnstile() { /* component */ }
|
||||
```
|
||||
|
||||
### SPA: Navigation Without Cleanup
|
||||
**Problem:** Navigating leaves orphaned widgets.
|
||||
|
||||
**Solution:** Remove widget in cleanup.
|
||||
```javascript
|
||||
// Vue
|
||||
onBeforeUnmount(() => window.turnstile.remove(widgetId));
|
||||
|
||||
// React
|
||||
useEffect(() => () => window.turnstile.remove(widgetId), []);
|
||||
```
|
||||
|
||||
## Network & Security
|
||||
|
||||
### CSP Blocking
|
||||
**Problem:** Content Security Policy blocks script/iframe.
|
||||
|
||||
**Solution:** Add CSP directives.
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self' https://challenges.cloudflare.com;
|
||||
frame-src https://challenges.cloudflare.com;">
|
||||
```
|
||||
|
||||
### IP Address Forwarding
|
||||
**Problem:** Server receives proxy IP instead of client IP.
|
||||
|
||||
**Solution:** Use correct header.
|
||||
```javascript
|
||||
// Cloudflare Workers
|
||||
const ip = request.headers.get('CF-Connecting-IP');
|
||||
|
||||
// Behind proxy
|
||||
const ip = request.headers.get('X-Forwarded-For')?.split(',')[0];
|
||||
```
|
||||
|
||||
### CORS (Siteverify)
|
||||
**Problem:** CORS error calling siteverify from browser.
|
||||
|
||||
**Solution:** Never call siteverify client-side. Call your backend, backend calls siteverify.
|
||||
|
||||
## Limits & Constraints
|
||||
|
||||
| Limit | Value | Impact |
|
||||
|-------|-------|--------|
|
||||
| Token validity | 5 minutes | Must regenerate after expiry |
|
||||
| Token use | Single-use | Cannot revalidate same token |
|
||||
| Widget size | 300x65px (normal), 130x120px (compact) | Plan layout |
|
||||
|
||||
## Debugging
|
||||
|
||||
### Console Logging
|
||||
```javascript
|
||||
window.turnstile.render('#container', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
callback: (token) => console.log('✓ Token:', token),
|
||||
'error-callback': (code) => console.error('✗ Error:', code),
|
||||
'expired-callback': () => console.warn('⏱ Expired'),
|
||||
'timeout-callback': () => console.warn('⏱ Timeout')
|
||||
});
|
||||
```
|
||||
|
||||
### Check Token State
|
||||
```javascript
|
||||
const token = window.turnstile.getResponse(widgetId);
|
||||
console.log('Token:', token || 'NOT READY');
|
||||
console.log('Expired:', window.turnstile.isExpired(widgetId));
|
||||
```
|
||||
|
||||
### Test Keys (Use First)
|
||||
Always develop with test keys before production:
|
||||
- Site: `1x00000000000000000000AA`
|
||||
- Secret: `1x0000000000000000000000000000000AA`
|
||||
|
||||
### Network Tab
|
||||
- Verify `api.js` loads (200 OK)
|
||||
- Check siteverify request/response
|
||||
- Look for 4xx/5xx errors
|
||||
|
||||
## Misconfigurations
|
||||
|
||||
### Wrong Key Pairing
|
||||
**Problem:** Site key from one widget, secret from another.
|
||||
|
||||
**Solution:** Verify site key and secret are from same widget in dashboard.
|
||||
|
||||
### Test Keys in Production
|
||||
**Problem:** Using test keys in production.
|
||||
|
||||
**Solution:** Environment-based keys.
|
||||
```javascript
|
||||
const SITE_KEY = process.env.NODE_ENV === 'production'
|
||||
? process.env.TURNSTILE_SITE_KEY
|
||||
: '1x00000000000000000000AA';
|
||||
```
|
||||
|
||||
### Missing Environment Variables
|
||||
**Problem:** Secret undefined on server.
|
||||
|
||||
**Solution:** Check .env and verify loading.
|
||||
```bash
|
||||
# .env
|
||||
TURNSTILE_SECRET=your_secret_here
|
||||
|
||||
# Verify
|
||||
console.log('Secret loaded:', !!process.env.TURNSTILE_SECRET);
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
- [Turnstile Docs](https://developers.cloudflare.com/turnstile/)
|
||||
- [Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile)
|
||||
- [Error Codes](https://developers.cloudflare.com/turnstile/troubleshooting/)
|
||||
@@ -0,0 +1,193 @@
|
||||
# Common Patterns
|
||||
|
||||
## Form Integration
|
||||
|
||||
### Basic Form (Implicit Rendering)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<form action="/submit" method="POST">
|
||||
<input type="email" name="email" required>
|
||||
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Controlled Form (Explicit Rendering)
|
||||
|
||||
```javascript
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
<script>
|
||||
let widgetId = window.turnstile.render('#container', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
callback: (token) => console.log('Token:', token)
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const token = window.turnstile.getResponse(widgetId);
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch('/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ 'cf-turnstile-response': token })
|
||||
});
|
||||
|
||||
if (!response.ok) window.turnstile.reset(widgetId);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Framework Patterns
|
||||
|
||||
### React
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import Turnstile from '@marsidev/react-turnstile';
|
||||
|
||||
export default function Form() {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ 'cf-turnstile-response': token })
|
||||
});
|
||||
}}>
|
||||
<Turnstile siteKey="YOUR_SITE_KEY" onSuccess={setToken} />
|
||||
<button disabled={!token}>Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Vue / Svelte
|
||||
|
||||
```vue
|
||||
<!-- Vue: npm install vue-turnstile -->
|
||||
<VueTurnstile :site-key="SITE_KEY" @success="token = $event" />
|
||||
|
||||
<!-- Svelte: npm install svelte-turnstile -->
|
||||
<Turnstile siteKey={SITE_KEY} on:turnstile-callback={(e) => token = e.detail.token} />
|
||||
```
|
||||
|
||||
## Server Validation
|
||||
|
||||
### Cloudflare Workers
|
||||
|
||||
```typescript
|
||||
interface Env {
|
||||
TURNSTILE_SECRET: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const token = formData.get('cf-turnstile-response');
|
||||
|
||||
if (!token) {
|
||||
return new Response('Missing token', { status: 400 });
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const ip = request.headers.get('CF-Connecting-IP');
|
||||
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
secret: env.TURNSTILE_SECRET,
|
||||
response: token,
|
||||
remoteip: ip
|
||||
})
|
||||
});
|
||||
|
||||
const validation = await result.json();
|
||||
|
||||
if (!validation.success) {
|
||||
return new Response('CAPTCHA validation failed', { status: 403 });
|
||||
}
|
||||
|
||||
// Process form...
|
||||
return new Response('Success');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Pages Functions
|
||||
|
||||
```typescript
|
||||
// functions/submit.ts - same pattern as Workers, use ctx.env and ctx.request
|
||||
export const onRequestPost: PagesFunction<{ TURNSTILE_SECRET: string }> = async (ctx) => {
|
||||
const token = (await ctx.request.formData()).get('cf-turnstile-response');
|
||||
// Validate with ctx.env.TURNSTILE_SECRET (same as Workers pattern above)
|
||||
};
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Pre-Clearance (Invisible)
|
||||
|
||||
```html
|
||||
<div id="turnstile-precheck"></div>
|
||||
<form id="protected-form" style="display: none;">
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
<script>
|
||||
let cachedToken = null;
|
||||
|
||||
window.onload = () => {
|
||||
window.turnstile.render('#turnstile-precheck', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
size: 'invisible',
|
||||
callback: (token) => {
|
||||
cachedToken = token;
|
||||
document.getElementById('protected-form').style.display = 'block';
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
### Token Refresh on Expiry
|
||||
|
||||
```javascript
|
||||
let widgetId = window.turnstile.render('#container', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
'refresh-expired': 'manual',
|
||||
'expired-callback': () => {
|
||||
console.log('Token expired, refreshing...');
|
||||
window.turnstile.reset(widgetId);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Environment-Based Keys
|
||||
|
||||
```javascript
|
||||
const SITE_KEY = process.env.NODE_ENV === 'production'
|
||||
? 'YOUR_PRODUCTION_SITE_KEY'
|
||||
: '1x00000000000000000000AA'; // Always passes
|
||||
|
||||
const SECRET_KEY = process.env.NODE_ENV === 'production'
|
||||
? process.env.TURNSTILE_SECRET
|
||||
: '1x0000000000000000000000000000000AA';
|
||||
```
|
||||
Reference in New Issue
Block a user