6.1 KiB
Troubleshooting & Gotchas
Critical Rules
❌ Skipping Server-Side Validation
Problem: Client-only validation is easily bypassed.
Solution: Always validate on server.
// 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.
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.
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.
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.
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.
'use client';
export default function Turnstile() { /* component */ }
SPA: Navigation Without Cleanup
Problem: Navigating leaves orphaned widgets.
Solution: Remove widget in cleanup.
// 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.
<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.
// 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
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
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.jsloads (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.
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.
# .env
TURNSTILE_SECRET=your_secret_here
# Verify
console.log('Secret loaded:', !!process.env.TURNSTILE_SECRET);