We Killed Bot Spam on Our Lead Forms Without Making a Single Human Click a Traffic Light
How we added Cloudflare Turnstile to every public form on our website, and the four small details that actually decided whether it worked

The problem: a sales inbox that stopped being trusted
Every lead form on our marketing site - Contact Us, Request a Demo, Partner With Us, and the blog subscribe box - was completely open. No bot protection. Nothing.
You can guess what happened. A slow but steady drip of junk submissions. Names like asdf asdf. Throwaway email addresses. "Messages" that were obviously written by a script, not a person.
And here’s the part that hurt: each fake submission still went through our normal, happy-path flow. A confirmation email went out to the fake “customer.” An internal notification went to smsales@***.com. So our sales team kept getting pinged about leads that were never real people.
The real damage wasn’t the emails. It was the trust. When enough notifications are garbage, people stop opening the ones that aren’t. A notification you ignore is worse than no notification at all.
Why we didn’t reach for a classic CAPTCHA
The obvious fix is a CAPTCHA - “select all squares with a bus.”
We said no, for one reason: friction lands on the wrong person.
Think about who is on our Request a Demo page. Someone who has already decided they want to talk to sales. That is the single most valuable visitor on the entire website. Making that person squint at blurry traffic lights, and sometimes fail, and have to try again - is an expensive way to block a bot. You block some bots, and you also lose some real revenue.
We wanted something that stops bots without real humans noticing anything happened at all.
Enter Cloudflare Turnstile
Turnstile is Cloudflare’s CAPTCHA alternative. Instead of asking the visitor to solve a puzzle, it runs quiet checks in the browser in the background - looking at browser signals and behaviour - and decides whether you smell like a human or a script.
A simple way to picture it:
A CAPTCHA is a bouncer who stops everyone and asks for ID. Turnstile is a bouncer who watches how you walk in, and only stops you if something looks off.
Most legitimate visitors see nothing. No box, no puzzle, no delay. Only when Cloudflare is genuinely unsure does it show an interactive challenge.
That was exactly the trade we wanted.
How it’s wired in: two halves that must both exist
This is the part people get wrong, so let me say it loudly:
A Turnstile widget on your page protects nothing by itself.
The widget is just the thing that hands the browser a token. The token is only meaningful once your server asks Cloudflare, “Hey, is this token real?”
So our implementation has two halves.
Half 1: The client-side widget
components/Turnstile.js is a thin wrapper around Cloudflare's script:
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
onReady={renderWidget}
/>
<div ref={containerRef} />
We render the widget with appearance: 'interaction-only' and a light theme. That appearance mode means the widget stays invisible for a clean visitor and only shows up if Cloudflare actually needs a human interaction to clear the check. (Cloudflare supports three appearance modes: always, which is the default, execute, and interaction-only.)
One design choice worth explaining. The component exposes an imperative handle - ref.current.getToken() and ref.current.reset() instead of just firing a callback when the token arrives.
Why? Because of timing. Verification starts in the background as soon as the widget mounts, but it often isn’t finished by the time a fast typer fills the form and hits Submit. If your form assumes “the token will already be sitting in state by submit time,” you will randomly fail on quick users and on slow networks.
So our forms do this instead:
const token = await turnstileRef.current.getToken(8000);
Translation: wait up to 8 seconds for a token to be ready, then move on either way.
Half 2: The server-side verification
lib/verifyTurnstile.js is the half that actually enforces anything:
export async function verifyTurnstile(token, remoteip) {
if (!token) return false;
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
...(remoteip && { remoteip }),
}),
});
const data = await res.json();
return data.success === true;
}And every API route that handles a public form calls it first. Before validating business fields. Before touching the database. Before sending a single email:
const verified = await verifyTurnstile(token, req.headers['x-forwarded-for']);
if (!verified) {
res.status(400).json({ error: 'Verification failed. Please try again.' });
return;
}
That ordering is deliberate. The check is the gate, not a step in the middle of the pipeline. It runs in contact.js, request-demo.js, partner-with-us.js, and blog-subscribe.js. If Cloudflare says the token is bad, the request dies right there - no email send, no sales notification, no database write.
Three details that actually mattered
These are the things that turned a working demo into a working production feature.
1. The script might already be loaded - use onReady, not onLoad
Our site is a Next.js Pages Router SPA. A visitor can land on /contact, then client-navigate to /request-demo. There's no full page reload. The Turnstile <Script> mounts again, but api.js is already sitting in memory.
Here’s the trap: in next/script, onLoad runs after the script finishes downloading - which happens once. onReady runs after the script has loaded and every time the component mounts. That's precisely the SPA re-navigation case.
If we had used onLoad, the widget would have worked on the first form the visitor saw and silently failed on every form after it. That's a nasty bug, because it works perfectly in local testing where you keep refreshing the page.
Rule of thumb: in an SPA, if your third-party script needs to do something on every page, wire it to onReady.
2. A missing token on the client is not a reason to block
If getToken() comes back null verification failed, or just timed out - we still let the request go to the API.
That sounds backwards. It isn’t. The server is the only place enforcement actually counts, and it will reject a missing or bad token anyway. Blocking on the client would only punish the legitimate slow-typer stuck in a timing race, while doing nothing extra to a bot (a bot doesn’t run your client code in the first place).
The principle: the client is a convenience layer. The server is the security layer. Never confuse the two.
3. Reset the widget after a failed submission
Turnstile tokens are single-use and expire after 300 seconds (5 minutes). A token that has already been sent to siteverify is dead - replaying it comes back as a failure with a timeout-or-duplicate error code.
So if a submit fails for any reason - bad verification, network blip, 500 from the server - we call:
turnstileRef.current.reset();
Without this, a user who fails once is stuck in a loop: they hit Submit again, the form re-sends the same burnt token, and it fails again. Forever. From their side, it looks like your website is simply broken.
Two more Turnstile rules worth knowing before you ship
Don’t proxy or self-host api.js. Cloudflare requires it to be fetched from their exact URL. Caching a copy on your own CDN feels like a performance win, but it breaks the moment Cloudflare ships an update.
Watch the 5-minute token window. Because we generate the token when the widget mounts, a visitor who takes a very long time on a long form could theoretically submit an expired token. In practice, Turnstile’s refresh-expired setting defaults to auto, so the widget re-runs the challenge and mints a fresh token by itself. But if you have file uploads or genuinely long forms, the safer pattern is Cloudflare's execute mode - render the widget but defer the challenge, then call turnstile.execute() from your submit handler so the token is created at the moment of submission, not five minutes earlier.
The result
Every public-facing form on the site - contact, demo request, partner inquiries, blog subscribe - now verifies a Turnstile token on the server before any email is sent or any row is written.
For real visitors, effectively nothing changed. No puzzle, no checkbox, no extra step. Most of them will never know Turnstile is there, which is exactly the point.
For bots, the submission dies at the API layer instead of becoming noise in a sales inbox.