Cloudflare Turnstile
Privacy-preserving CAPTCHA alternative using Cloudflare Turnstile for bot protection with server-side verification in Next.js and Express
You are an expert in using Cloudflare Turnstile for bot detection and challenge-based protection as a privacy-first alternative to reCAPTCHA. ## Key Points 1. Go to Cloudflare Dashboard > Turnstile. 2. Add a site and note your **Site Key** (public) and **Secret Key** (server-side). 3. Choose widget mode: Managed, Non-interactive, or Invisible. - Always verify the Turnstile token server-side — the client-side widget only collects the token; without server verification it provides zero security. - Use Turnstile's `action` parameter to bind tokens to specific forms (login vs. registration), preventing token reuse across different endpoints. - Implement token expiry handling on the client by using the `expired-callback` to clear state and prompt re-verification before form submission. ## Quick Example ```bash # .env.local NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA... TURNSTILE_SECRET_KEY=0x4AAAAAAA... ```
skilldb get security-ratelimit-skills/Cloudflare TurnstileFull skill: 308 linesCloudflare Turnstile — Security & Rate Limiting
You are an expert in using Cloudflare Turnstile for bot detection and challenge-based protection as a privacy-first alternative to reCAPTCHA.
Core Philosophy
Overview
Cloudflare Turnstile is a free CAPTCHA replacement that verifies visitors are human without showing puzzles. It uses Cloudflare's machine learning signals — browser fingerprinting, proof-of-work challenges, and behavioral analysis — to issue a token that your server validates via Turnstile's siteverify API. Turnstile offers three widget modes: managed (Cloudflare decides if a challenge is needed), non-interactive (invisible, no user action), and invisible (fully hidden). It protects login forms, registration flows, and API endpoints from bots without degrading user experience.
Setup & Configuration
Cloudflare Dashboard Setup
- Go to Cloudflare Dashboard > Turnstile.
- Add a site and note your Site Key (public) and Secret Key (server-side).
- Choose widget mode: Managed, Non-interactive, or Invisible.
Environment Variables
# .env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA...
TURNSTILE_SECRET_KEY=0x4AAAAAAA...
Client-Side Widget (React)
// components/TurnstileWidget.tsx
"use client";
import { useEffect, useRef } from "react";
interface TurnstileWidgetProps {
onVerify: (token: string) => void;
onExpire?: () => void;
}
declare global {
interface Window {
turnstile: {
render: (
element: HTMLElement,
options: Record<string, unknown>
) => string;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
}
}
export function TurnstileWidget({
onVerify,
onExpire,
}: TurnstileWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
useEffect(() => {
const script = document.createElement("script");
script.src =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.onload = () => {
if (containerRef.current && !widgetIdRef.current) {
widgetIdRef.current = window.turnstile.render(
containerRef.current,
{
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
callback: onVerify,
"expired-callback": onExpire,
theme: "auto",
}
);
}
};
document.head.appendChild(script);
return () => {
if (widgetIdRef.current) {
window.turnstile.remove(widgetIdRef.current);
}
script.remove();
};
}, [onVerify, onExpire]);
return <div ref={containerRef} />;
}
Core Patterns
Server-Side Token Verification (Next.js Route Handler)
// app/api/verify-turnstile/route.ts
import { NextRequest, NextResponse } from "next/server";
interface TurnstileResponse {
success: boolean;
"error-codes": string[];
challenge_ts: string;
hostname: string;
action: string;
cdata: string;
}
export async function POST(request: NextRequest) {
const { token, ...formData } = await request.json();
if (!token) {
return NextResponse.json(
{ error: "Missing Turnstile token" },
{ status: 400 }
);
}
const verification = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY!,
response: token,
remoteip:
request.headers.get("x-forwarded-for") ?? "127.0.0.1",
}),
}
);
const result: TurnstileResponse = await verification.json();
if (!result.success) {
return NextResponse.json(
{ error: "Turnstile verification failed", codes: result["error-codes"] },
{ status: 403 }
);
}
// Token is valid — process the form submission
return NextResponse.json({ success: true });
}
Express Middleware
// middleware/turnstile.ts
import { Request, Response, NextFunction } from "express";
export async function verifyTurnstile(
req: Request,
res: Response,
next: NextFunction
) {
const token = req.body["cf-turnstile-response"] || req.body.turnstileToken;
if (!token) {
return res.status(400).json({ error: "Missing Turnstile token" });
}
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY!,
response: token,
remoteip: req.ip ?? "",
}),
}
);
const result = await response.json();
if (!result.success) {
return res.status(403).json({ error: "Bot verification failed" });
}
next();
}
// Usage:
// app.post("/api/register", verifyTurnstile, registerHandler);
Form Integration with React Hook Form
// components/RegistrationForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { TurnstileWidget } from "./TurnstileWidget";
interface FormValues {
email: string;
password: string;
}
export function RegistrationForm() {
const [turnstileToken, setTurnstileToken] = useState<string | null>(
null
);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>();
const onSubmit = async (data: FormValues) => {
if (!turnstileToken) {
alert("Please complete the verification");
return;
}
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, token: turnstileToken }),
});
if (!response.ok) {
const error = await response.json();
console.error("Registration failed:", error);
return;
}
// Registration successful
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="email" {...register("email", { required: true })} />
<input
type="password"
{...register("password", { required: true })}
/>
<TurnstileWidget
onVerify={setTurnstileToken}
onExpire={() => setTurnstileToken(null)}
/>
<button type="submit" disabled={!turnstileToken}>
Register
</button>
</form>
);
}
Testing with Turnstile Test Keys
// Use Cloudflare's test keys in development/test environments
// These always pass or fail deterministically
// Always passes:
// Site key: 1x00000000000000000000AA
// Secret key: 1x0000000000000000000000000000000AA
// Always blocks:
// Site key: 2x00000000000000000000AB
// Secret key: 2x0000000000000000000000000000000AB
// Always shows interactive challenge:
// Site key: 3x00000000000000000000FF
// Secret key: (use the always-passes secret)
const siteKey =
process.env.NODE_ENV === "production"
? process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
: "1x00000000000000000000AA";
Best Practices
- Always verify the Turnstile token server-side — the client-side widget only collects the token; without server verification it provides zero security.
- Use Turnstile's
actionparameter to bind tokens to specific forms (login vs. registration), preventing token reuse across different endpoints. - Implement token expiry handling on the client by using the
expired-callbackto clear state and prompt re-verification before form submission.
Common Pitfalls
- Forgetting to handle the case where the Turnstile script fails to load (ad blockers, network issues) — always provide a fallback or clear error message rather than silently disabling the submit button forever.
- Verifying the same token multiple times — Turnstile tokens are single-use. After server verification, store the result in your session rather than re-verifying the same token on subsequent requests.
Anti-Patterns
Over-engineering for hypothetical requirements. Building for scenarios that may never materialize adds complexity without value. Solve the problem in front of you first.
Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide wastes time and introduces risk.
Premature abstraction. Creating elaborate frameworks before having enough concrete cases to know what the abstraction should look like produces the wrong abstraction.
Neglecting error handling at system boundaries. Internal code can trust its inputs, but boundaries with external systems require defensive validation.
Skipping documentation. What is obvious to you today will not be obvious to your colleague next month or to you next year.
Install this skill directly: skilldb add security-ratelimit-skills
Related Skills
Arcjet
Rate limiting, bot detection, email validation, and shield attack protection using Arcjet with Next.js middleware and stacking rules
Security Headers
Security headers with Helmet.js, Content Security Policy, CORS configuration, CSRF protection, rate limiting patterns, and Next.js security headers
OWASP ZAP
Automated web application security testing, API scanning, and CI/CD DAST integration using OWASP ZAP
Snyk
Dependency vulnerability scanning, license compliance, and continuous security monitoring using Snyk CLI and CI/CD integrations
Svix
Webhook delivery infrastructure including sending webhooks, retry logic, signature verification, event types, consumer portal, and message logging with Svix
Unkey
API key management, rate limiting, usage tracking, key verification, temporary keys, ratelimit API, and analytics with Unkey