Files
dotfiles/.agents/skills/cloudflare-deploy/references/turnstile/gotchas.md
2026-03-17 16:53:22 -07:00

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.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.

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);

Reference