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.