Skip to content

Cloudflare Turnstile: The CAPTCHA That Doesn’t Make Your Users Hate You

reCAPTCHA hurts conversion. Turnstile doesn’t. Here’s the wiring that keeps your forms spam-free without the click-the-bicycles ritual.

John Cravey with EleviFounder4 min read

Every public form on an FH client site has Cloudflare Turnstile validating it server-side. We migrated from reCAPTCHA two years ago and never looked back. Turnstile is invisible to the user 99% of the time, blocks the same bot traffic, is free, and respects user privacy in a way reCAPTCHA doesn’t. Here’s the integration we run across the client book.

Why reCAPTCHA hurts you

reCAPTCHA v2 (the “I’m not a robot” checkbox + bicycle-puzzle) demonstrably hurts conversion rate. We A/B tested it on three client lead forms. The conversion drop ranged from 8% to 18%. The frustration tax compounds: every user who fails the puzzle once is less likely to retry, especially on mobile.

reCAPTCHA v3 (score-based, invisible) is better but Google still tracks every page where it’s embedded. For sites taking marketing claims about privacy seriously, that’s a problem.

What Turnstile does differently

Turnstile runs a few non-invasive checks in the user’s browser (browser fingerprint, environment consistency, behavioral signals) and produces a token. The token is sent with the form submission. Your server validates the token with Cloudflare’s API. No tracking, no puzzles, no Google. The full check takes ~200ms and is invisible.

When Turnstile is unsure, it falls back to a managed challenge — a single click, no puzzle. We see this on <1% of submissions across the FH client book.

Step 1: create a Turnstile site key

Cloudflare dashboard → Turnstile → Add Site. Pick the “Managed” mode (lets Cloudflare decide invisible vs. challenge). You get a site key (public) and a secret key (server-only). Add the domain(s) the form will live on.

Step 2: render the widget in your form

The widget is a `<div>` with a Cloudflare-loaded script. On submit, the widget produces a hidden form field containing the token.

// app/contact/page.tsx (server component)
import { ContactForm } from "./ContactForm";

export default function Contact() {
  return <ContactForm siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} />;
}

// app/contact/ContactForm.tsx
"use client";
import Script from "next/script";
import { submitLead } from "./actions";

export function ContactForm({ siteKey }: { siteKey: string }) {
  return (
    <>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
      <form action={submitLead}>
        <input name="name" required />
        <input name="email" type="email" required />
        <div className="cf-turnstile" data-sitekey={siteKey} />
        <button>Send</button>
      </form>
    </>
  );
}

Step 3: verify the token server-side

The widget puts a token in a hidden field called `cf-turnstile-response`. Your server action reads it, POSTs it to Cloudflare’s siteverify endpoint, and only inserts the lead if the response is successful.

// app/contact/actions.ts
"use server";
import "server-only";
import { z } from "zod";

const LeadSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  "cf-turnstile-response": z.string().min(1),
});

async function verifyTurnstile(token: string, ip: string | null): Promise<boolean> {
  const res = 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,
      ...(ip ? { remoteip: ip } : {}),
    }),
  });
  const data = await res.json();
  return data.success === true;
}

export async function submitLead(formData: FormData) {
  const parsed = LeadSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { ok: false };
  const ok = await verifyTurnstile(parsed.data["cf-turnstile-response"], null);
  if (!ok) return { ok: false, reason: "turnstile" };
  // insert lead
  return { ok: true };
}

What to do when verification fails

Three options. (1) Silent drop: return `{ ok: true }` and discard the submission. Confusing to legitimate users who somehow failed. (2) Hard error: tell the user verification failed and ask them to retry. Honest but annoying. (3) Soft challenge: ask them to retry the form. We default to option 2 with a friendly error message — “Hmm, we couldn’t verify that submission. Please refresh and try again.” In practice fewer than 1 in 1000 legitimate submissions need this.

Mobile considerations

Turnstile works on mobile out of the box — no extra config. It’s slightly slower (the fingerprint checks run longer) but still under 500ms in our measurements. If you’re seeing complaints about mobile form latency, check the images on the page before blaming Turnstile.

Hidden form fields and honeypots

Turnstile is enough on its own. But adding a honeypot field — an invisible input that bots fill in and humans don’t — catches the cheapest bots before they even hit Turnstile. We add a `<input name="website" tabindex=-1 style="position:absolute;left:-9999px">` and reject any submission where it’s non-empty.

Migrating from reCAPTCHA

Three steps: (1) Replace the reCAPTCHA script tag with the Turnstile one. (2) Replace `<div class="g-recaptcha">` with `<div class="cf-turnstile">`. (3) Replace the server-side verification call. Total time: 30 minutes per form. Watch your spam volume for a week to confirm the change doesn’t miss anything.

Cost

Free up to 10M solves per month. We’re not close. Premium tiers add features we don’t need.

When Turnstile isn’t enough

For high-value targets (financial logins, healthcare patient portals, anything where credential stuffing is worth attacker time), pair Turnstile with: rate-limiting at the edge (Cloudflare WAF rule), 2FA on the account, and behavioral anomaly detection. Turnstile is the front line; it’s not the only line.

How this lands across FH client work

Every public form on every FH client site uses Turnstile. Zero spam complaints in 18 months. Zero conversion drop attributable to verification. If you’re still on reCAPTCHA, book a consultation — the migration is a 30-minute change per form with a measurable conversion lift.

Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
Accessibility Law in 2026: The Lawsuit Landscape and the Compliant Build Posture
Older post
Google Analytics 4 Consent Mode v2: The Implementation That Doesn’t Break Your Data
Keep reading

More from the blog

Next.js·6 min

Next.js 16.1 in Production: The Migration Playbook We Run on Every FH Site

Next 16.1 is the lean target. Here’s the exact migration we run, what breaks, and what to delete after.

Cloudflare·6 min

Cloudflare DNS and CDN: The Base Configuration for Every FH Client Site

Every FH site sits behind Cloudflare. Here’s the exact configuration and why each setting is where it is.

Next.js·4 min

Server Components vs Client Components: The Mental Model That Stops You Reaching for ‘use client’

Most teams add ‘use client’ because they’re scared. The bundle pays for it.