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

  1. Go to the Cloudflare dashboard > Turnstile
  2. Click Add site
  3. Enter your domain (or localhost for development)
  4. Choose widget mode:
ModeBehaviorBest for
ManagedCF decides if a challenge is neededMost forms
Non-interactiveNever shows a challenge widgetAPI endpoints, invisible protection
InvisibleNo visible widget, fallback challenge if neededLogin forms, high-security pages
  1. 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 siteverify API. 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 scenarioSite KeySecret Key
Always passes1x00000000000000000000AA1x0000000000000000000000000000000AA
Always fails2x00000000000000000000AB2x0000000000000000000000000000000AB
Forces interactive challenge3x00000000000000000000FFN/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:

  1. User loads the login page
  2. Turnstile widget verifies the browser in the background
  3. User submits email + password + Turnstile token
  4. Worker verifies the Turnstile token via siteverify
  5. If valid, authenticates credentials against D1
  6. Issues a session token stored in KV (with 24h TTL)
  7. 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.