Cloudflare Turnstile
Invisible bot protection that blocks spam without CAPTCHAs or user friction.
Why Turnstile?
| Turnstile | reCAPTCHA | Traditional CAPTCHA | |
|---|---|---|---|
| User friction | Invisible | Low | High (puzzles) |
| Privacy | No tracking | Tracks users | Varies |
| Free tier | 1M/month | 1M/month | Usually paid |
| Setup time | 2 minutes | 5 minutes | 10+ minutes |
Turnstile uses smart analysis to distinguish humans from bots. Most users pass without any interaction.
Setup
1. Create a Turnstile Site
- Log in to the Cloudflare Dashboard
- Click Turnstile in the sidebar, then Add site
- Enter your site name and domain(s) - add
localhostfor development - Choose a widget mode:
- Managed (recommended) - invisible for most users, shows challenge only when suspicious
- Non-interactive - always invisible
- Invisible - background check, may show checkbox
- Visible - always shows a checkbox
- Copy the Site Key (public) and Secret Key (private)
2. Configure Environment
Add to .env:
CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAxxxxxxxxxxxxxxxxxx
CLOUDFLARE_TURNSTILE_SECRET_KEY=0x4AAAAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxTo disable Turnstile, leave both variables empty or remove them.
The built-in clients at /, /embed.js, and /api/signup/form automatically render and submit Turnstile when both keys are configured. Custom frontends should read /api/config to discover the public site key.
3. Add to Your Frontend
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form id="signup-form" action="/api/signup" method="POST">
<input type="email" name="email" placeholder="Email" required>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Sign Up</button>
</form>The widget generates a turnstileToken that is sent with the form. The API verifies it with Cloudflare before processing the signup.
JavaScript Example
const config = await fetch('/api/config').then((response) => response.json());
if (config.turnstileEnabled && config.turnstileSiteKey) {
turnstile.render('#turnstile-container', {
sitekey: config.turnstileSiteKey,
callback: (token) => {
fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, turnstileToken: token })
});
}
});
}React Example
import { useEffect, useRef } from 'react';
export default function SignupForm() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
script.async = true;
document.head.appendChild(script);
script.onload = () => {
if (window.turnstile && containerRef.current) {
window.turnstile.render(containerRef.current, {
sitekey: process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY,
});
}
};
return () => { document.head.removeChild(script); };
}, []);
return (
<form action="/api/signup" method="POST">
<input type="email" name="email" required />
<div ref={containerRef} />
<button type="submit">Sign Up</button>
</form>
);
}Testing
Cloudflare provides test keys for development:
| Behavior | Site Key | Secret Key |
|---|---|---|
| Always passes | 1x00000000000000000000AA | 1x0000000000000000000000000000000AA |
| Always fails | 2x00000000000000000000AB | 2x0000000000000000000000000000000AA |
| Timeouts | 3x00000000000000000000FF | 3x0000000000000000000000000000000AA |
# Test with token
curl -X POST http://localhost:3000/api/signup \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "turnstileToken": "valid-test-token"}'Bulk requests use a single request-level token:
curl -X POST http://localhost:3000/api/signup/bulk \
-H "Content-Type: application/json" \
-d '{"turnstileToken": "valid-test-token", "signups": [{"email": "a@example.com"}, {"email": "b@example.com"}]}'Widget Customization
<!-- Dark theme -->
<div class="cf-turnstile" data-sitekey="KEY" data-theme="dark"></div>
<!-- Language -->
<div class="cf-turnstile" data-sitekey="KEY" data-language="es"></div>
<!-- Auto retry -->
<div class="cf-turnstile" data-sitekey="KEY" data-retry="auto"></div>Theme options: light, dark, auto
Troubleshooting
"Invalid Turnstile token": Token may be expired (5 min TTL), already used (single-use), or malformed. Check that the secret key matches the site key (same Turnstile site).
Widget not showing: Verify the Turnstile script is loaded, the container div exists, and the site key is correct. Some ad blockers may interfere. If /api/config reports turnstileEnabled: true but turnstileSiteKey: null, the server is enforcing Turnstile without a public key available to the browser.
Localhost not working: Add localhost to your Turnstile site domains. Use http://localhost, not http://127.0.0.1.
Migrating from reCAPTCHA
Replace the script and widget div:
<!-- reCAPTCHA (old) -->
<script src="https://www.google.com/recaptcha/api.js"></script>
<div class="g-recaptcha" data-sitekey="RECAPTCHA_KEY"></div>
<!-- Turnstile (new) -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
<div class="cf-turnstile" data-sitekey="TURNSTILE_KEY"></div>The server-side verification is handled automatically by subs.
Next Steps
- Google Sheets Setup - Configure storage
- API Reference - All endpoints
- Deployment - Production deployment