Turnstile
Set up Cloudflare Turnstile to protect forms from bots. Turnstile replaces traditional CAPTCHAs with invisible browser challenges - no puzzles for users to solve. The Webhook Hub uses it to protect its dashboard login form.
Prerequisites: Networking Essentials, First Worker
Create a Turnstile Widget
- Go to the Cloudflare dashboard > Turnstile
- Click Add site
- Enter your domain (or
localhostfor development) - Choose widget mode:
| Mode | Behavior | Best for |
|---|---|---|
| Managed | CF decides if a challenge is needed | Most forms |
| Non-interactive | Never shows a challenge widget | API endpoints, invisible protection |
| Invisible | No visible widget, fallback challenge if needed | Login forms, high-security pages |
- Copy the Site Key (public, goes in HTML) and Secret Key (private, goes in your Worker)
Store the secret key as a Worker secret:
npx wrangler secret put TURNSTILE_SECRET
Add the site key as a plain variable in wrangler.jsonc:
{
"vars": {
"TURNSTILE_SITE_KEY": "0x4AAAAAAA..."
}
}
Client-Side Integration
HTML
Add the Turnstile script and widget to your form:
<!DOCTYPE html>
<html>
<head>
<title>Webhook Hub - Login</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<form id="login-form" action="/auth/login" method="POST">
<label for="email">Email</label>
<input type="email" name="email" id="email" required />
<label for="password">Password</label>
<input type="password" name="password" id="password" required />
<!-- Turnstile widget renders here -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-callback="onTurnstileSuccess"></div>
<button type="submit" id="submit-btn" disabled>Log in</button>
</form>
<script>
function onTurnstileSuccess(token) {
document.getElementById("submit-btn").disabled = false;
}
</script>
</body>
</html>
The widget adds a hidden cf-turnstile-response field to the form when verification succeeds. The data-callback is optional - use it to enable the submit button after verification.
React
For React apps, render the widget with an explicit call:
import { useEffect, useRef, useState } from "react";
declare global {
interface Window {
turnstile: {
render: (
element: HTMLElement,
options: { sitekey: string; callback: (token: string) => void }
) => string;
reset: (widgetId: string) => void;
};
}
}
function TurnstileWidget({
siteKey,
onVerify,
}: {
siteKey: string;
onVerify: (token: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [widgetId, setWidgetId] = useState<string | null>(null);
useEffect(() => {
if (!ref.current || !window.turnstile) return;
const id = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onVerify,
});
setWidgetId(id);
return () => {
if (id) window.turnstile.reset(id);
};
}, [siteKey, onVerify]);
return <div ref={ref} />;
}
// Usage in a login form
function LoginForm() {
const [token, setToken] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!token) return;
const form = new FormData(e.currentTarget);
form.append("cf-turnstile-response", token);
const res = await fetch("/auth/login", { method: "POST", body: form });
// handle response...
}
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" required />
<input type="password" name="password" required />
<TurnstileWidget
siteKey="YOUR_SITE_KEY"
onVerify={(t) => setToken(t)}
/>
<button type="submit" disabled={!token}>Log in</button>
</form>
);
}
Remember to load the Turnstile script in your HTML <head>:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
Server-Side Verification
Verify the Turnstile token by calling Cloudflare’s siteverify endpoint:
interface TurnstileVerifyResponse {
success: boolean;
"error-codes": string[];
challenge_ts: string;
hostname: string;
}
async function verifyTurnstile(
token: string,
secret: string,
remoteip?: string
): Promise<boolean> {
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret,
response: token,
remoteip,
}),
}
);
const result = await response.json<TurnstileVerifyResponse>();
return result.success;
}
Use it in a Hono route:
app.post("/auth/login", async (c) => {
const form = await c.req.formData();
const email = form.get("email") as string;
const password = form.get("password") as string;
const turnstileToken = form.get("cf-turnstile-response") as string;
// 1. Verify Turnstile token
if (!turnstileToken) {
return c.json({ error: "missing turnstile token" }, 400);
}
const isHuman = await verifyTurnstile(
turnstileToken,
c.env.TURNSTILE_SECRET,
c.req.header("CF-Connecting-IP")
);
if (!isHuman) {
return c.json({ error: "bot verification failed" }, 403);
}
// 2. Authenticate the user (your auth logic here)
const user = await authenticateUser(c.env.DB, email, password);
if (!user) {
return c.json({ error: "invalid credentials" }, 401);
}
// 3. Issue a session token
const sessionToken = crypto.randomUUID();
await c.env.CACHE.put(`session:${sessionToken}`, JSON.stringify({ userId: user.id, email }), {
expirationTtl: 86400, // 24 hours
});
return c.json({ token: sessionToken });
});
Gotcha: Turnstile tokens are single-use. Each token can only be verified once via the
siteverifyAPI. After verification, issue your own session token (stored in KV, D1, or a cookie) for subsequent requests. Attempting to verify the same Turnstile token twice will fail.
Turnstile as Middleware
Extract verification into reusable Hono middleware:
import { createMiddleware } from "hono/factory";
const turnstileGuard = createMiddleware<{ Bindings: Env }>(async (c, next) => {
// Skip for non-mutation requests
if (c.req.method === "GET" || c.req.method === "HEAD") {
return next();
}
const form = await c.req.formData();
const token = form.get("cf-turnstile-response") as string;
if (!token) {
return c.json({ error: "turnstile token required" }, 400);
}
const valid = await verifyTurnstile(
token,
c.env.TURNSTILE_SECRET,
c.req.header("CF-Connecting-IP")
);
if (!valid) {
return c.json({ error: "verification failed" }, 403);
}
return next();
});
// Apply to specific routes
app.post("/auth/login", turnstileGuard, async (c) => {
// Token already verified by middleware
// ...
});
// Or apply to a group of routes
const protectedForms = new Hono<{ Bindings: Env }>();
protectedForms.use("*", turnstileGuard);
protectedForms.post("/contact", async (c) => { /* ... */ });
protectedForms.post("/feedback", async (c) => { /* ... */ });
app.route("/forms", protectedForms);
Testing with Test Keys
Cloudflare provides test site keys and secret keys that always pass or always fail:
| Test scenario | Site Key | Secret Key |
|---|---|---|
| Always passes | 1x00000000000000000000AA | 1x0000000000000000000000000000000AA |
| Always fails | 2x00000000000000000000AB | 2x0000000000000000000000000000000AB |
| Forces interactive challenge | 3x00000000000000000000FF | N/A |
Use the “always passes” keys during development:
// wrangler.jsonc - dev overrides
{
"vars": {
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA"
}
}
npx wrangler secret put TURNSTILE_SECRET
# Enter: 1x0000000000000000000000000000000AA
Webhook Hub: Protected Dashboard
The Webhook Hub dashboard login is now protected by Turnstile:
- User loads the login page
- Turnstile widget verifies the browser in the background
- User submits email + password + Turnstile token
- Worker verifies the Turnstile token via
siteverify - If valid, authenticates credentials against D1
- Issues a session token stored in KV (with 24h TTL)
- Subsequent dashboard requests use the session token, not Turnstile
This prevents bot-driven credential stuffing and brute force attacks without adding friction for real users.
What’s Next
The core platform quickstarts are complete. You now have:
- Worker (First Worker) - Hono API on the edge
- Database (D1 CRUD) - structured storage with SQL
- Object storage (R2 Files) - large payloads and file uploads
- Caching (KV Caching) - edge cache and rate limiting
- Bot protection (this page) - Turnstile for forms
Next quickstarts add async processing, networking, and a full-stack dashboard to the Webhook Hub.