Protecting Public Forms: Cloudflare Turnstile + Real Email Verification
A step-by-step guide to stop bots AND stop humans who submit fake or throwaway emails. Turnstile answers 'is this a human?' — it does not answer 'is this a real, reachable email?'. This covers both layers: Turnstile setup with server-side verification across all three widget modes, then format + MX/DNS checks, disposable-domain blocking, and ownership confirmation via link or OTP.
Protecting Public Forms: Cloudflare Turnstile + Real Email Verification
By JB (Muke Johnbaptist) · July 2026. A guide for developers who want to stop bots and stop humans who submit fake or throwaway emails (e.g. hello@gmail.com, asdf@mailinator.com).
Why one tool isn't enough
Turnstile answers one question: "Is this a human, not a bot?" It does not answer: "Is this a real, reachable email address?"
A real person can pass Turnstile and still type a fake or disposable email. So a production-grade form needs two independent layers:
| Layer | Solves | Tool |
|---|---|---|
| 1. Bot protection | Scripted/automated spam | Cloudflare Turnstile |
| 2. Email validity | Fake/disposable/unreachable addresses | Format + MX/DNS check + disposable-domain block + confirmation email (OTP or link) |
This guide covers both, in order.
Reference implementation: the Turnstile portion follows the working example in harshil1712/rwsdk-turnstile-demo — a RedwoodSDK app demonstrating all three Turnstile modes with server-side verification, D1 storage via Prisma, an admin dashboard, and scheduled cleanup of stale entries.
Part 1: Cloudflare Turnstile
How Turnstile works under the hood
- You create a Turnstile widget in the Cloudflare dashboard and get a Site Key (public) and a Secret Key (private).
- You load Turnstile's JavaScript and embed the widget using the Site Key.
- Turnstile runs non-interactive JS challenges and ML checks in the background, then produces a token.
- Your form sends that token to your server with the form data.
- Your server calls Cloudflare's siteverify endpoint with the Secret Key + token.
- Only if verification succeeds do you accept the submission.
Golden rule: always verify the token server-side. Client-side presence of the widget proves nothing — the secret-key check is what actually secures the form.
Choosing a widget mode
| Mode | Behaviour | Best for |
|---|---|---|
| Managed | Non-interactive most of the time; challenges only suspicious traffic | General-purpose default |
| Non-interactive | Visible but never requires interaction | A visible trust badge with zero friction |
| Invisible | Runs entirely in the background | Maximum smoothness (login/checkout) |
Each mode requires its own widget (its own Site Key / Secret Key pair), even within the same project.
Step 1 — Create a widget
- Cloudflare dashboard → Turnstile → Add widget.
- Name it, enter the hostname(s) (
localhostfor dev, your real domain for prod). - Choose a mode, click Create, copy the Site Key and Secret Key.
Step 2 — Load the Turnstile script
In a client-rendered app, load it once the client is ready and clean it up on unmount so you don't end up with duplicate scripts on re-render:
useEffect(() => {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
document.head.appendChild(script);
return () => {
const existing = document.querySelector(
'script[src="https://challenges.cloudflare.com/turnstile/v0/api.js"]'
);
if (existing) document.head.removeChild(existing);
};
}, []);Loading the script this way (instead of a static tag in the HTML head) avoids hydration mismatches in frameworks like RedwoodSDK, Remix, or Next.js where the server doesn't render client-only widgets.
Step 3 — Add the widget to your form
<div className="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>You don't need data-callback unless you want a custom JS hook on success.
Step 4 — Verify the token on your server
Type the response from Cloudflare's siteverify endpoint, then wrap the call in a reusable function:
interface TurnstileResponse {
success: boolean;
challenge_ts?: string;
hostname?: string;
"error-codes"?: string[];
action?: string;
cdata?: string;
}
async function verifyTurnstileToken(
token: string,
secretKey: string
): Promise<TurnstileResponse> {
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: secretKey, response: token }),
}
);
return (await response.json()) as TurnstileResponse;
}Typing the response means your editor and TypeScript catch mistakes early — e.g. reading result.errorCodes instead of the actual result["error-codes"] field Cloudflare returns.
One secret per mode: since each mode is its own widget, use a separate secret key per mode. In the reference repo these are three separate Wrangler secrets (
MANAGED_TURNSTILE_SECRET_KEY,NON_INTERACTIVE_TURNSTILE_SECRET_KEY,INVISIBLE_TURNSTILE_SECRET_KEY), passed intoverifyTurnstileToken()depending on which widget submitted the token.
Step 5 — Wire it into your submit handler
async function submitForm(formData: { token: string; email: string }) {
try {
const { token, email } = formData;
if (!token)
return { success: false, message: "Turnstile token is missing." };
if (!email) return { success: false, message: "Email is required." };
const result = await verifyTurnstileToken(
token,
env.MANAGED_TURNSTILE_SECRET_KEY
);
if (!result.success) {
console.error("Turnstile verification failed:", result["error-codes"]);
return { success: false, message: "Turnstile verification failed." };
}
// Passed the bot check — but NOT yet proven to be a real, owned email.
// See Part 2 before treating this address as trustworthy.
await db.formSubmission.create({ data: { email } });
return { success: true, message: "Form submitted successfully." };
} catch (err) {
console.error(err);
return {
success: false,
message: "Something went wrong. Please try again.",
};
}
}Logging result["error-codes"] on failure (rather than just the boolean) makes debugging much faster — Cloudflare returns specific codes like timeout-or-duplicate or invalid-input-response that tell you exactly why a token was rejected.
Step 6 — Test all outcomes
Cloudflare provides dedicated test Site/Secret Keys (see "Testing" under "Troubleshoot" in the Turnstile docs) so you can force always-pass, always-fail, or always-interactive results without waiting for real traffic.
Part 2: Stopping fake / unreachable emails
A human can pass Turnstile and still type hello@gmail.com, test@test.com, or a temporary inbox. Use these layers, from cheap/fast to strongest.
Layer A — Format validation (client + server)
Reject obviously malformed addresses before touching anything else. Do this server-side even if you also do it client-side.
function isValidEmailFormat(email: string): boolean {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}This catches typos but not fake-but-well-formatted addresses like hello@gmail.com.
Layer B — Domain existence check (MX/DNS)
Confirm the domain can actually receive mail by checking its MX (or fallback A) records. Catches typo'd or nonexistent domains (e.g. user@gnail.com).
import { promises as dns } from "dns";
async function domainCanReceiveEmail(email: string): Promise<boolean> {
const domain = email.split("@")[1];
try {
const mx = await dns.resolveMx(domain);
return mx && mx.length > 0;
} catch {
return false;
}
}This confirms the domain accepts mail, not that the specific mailbox exists. A real domain like
gmail.comalways passes.
Layer C — Block disposable domains
Maintain (or fetch) a list of known disposable-email domains and reject them outright.
import disposableDomains from "disposable-email-domains";
function isDisposableEmail(email: string): boolean {
const domain = email.split("@")[1]?.toLowerCase();
return disposableDomains.includes(domain);
}Popular lists: disposable-email-domains (npm), or paid APIs like Kickbox, ZeroBounce, or Abstract API's email validation — these estimate real deliverability with a confidence score.
Layer D — The strongest check: confirm ownership
None of the checks above prove someone actually owns the inbox — only that the address is plausible. The only reliable way to prevent hello@gmail.com-style submissions is to require proof of access.
Option 1 — Verification link
- On submission, save the entry as
unverified. - Send an email with a unique, expiring token link (e.g.
https://yoursite.com/verify?token=abc123). - Mark
verifiedonly once the link is clicked. - Auto-expire unverified entries after 24–48 hours to keep your list clean.
Option 2 — One-Time Passcode (OTP)
- Send a 6-digit code to the submitted email.
- Require the user to enter it back before the submission is accepted.
- Slight friction, near-certain proof of ownership — good for waitlists and account creation.
Suggested combined flow
Form submit
│
▼
Turnstile token present? ── no ──▶ reject (bot suspected)
│ yes
▼
Turnstile siteverify success? ── no ──▶ reject
│ yes
▼
Email format valid? ── no ──▶ reject
│ yes
▼
Domain has MX record? ── no ──▶ reject
│ yes
▼
Domain in disposable list? ── yes ──▶ reject
│ no
▼
Store as "pending", send verification email/OTP
│
▼
User confirms ──▶ mark "verified" ──▶ done
(no confirmation within X hours ──▶ auto-delete)
This way Turnstile stops scripted spam at the door; format + MX + disposable checks stop garbage addresses cheaply without emailing anyone; and the verification link/OTP stops real humans from squatting your list with an email that isn't theirs.
Recap
- Turnstile has three modes — Managed (adaptive), Non-interactive (visible, frictionless), Invisible (hidden). Each needs its own key pair.
- Always verify the token server-side — that's what makes it secure.
- Turnstile proves "human", not "real email". For that, layer in format validation, MX/domain checks, disposable-domain blocking, and (for anything important) ownership confirmation via link or OTP.
- Reject fast and cheap first (format → MX → disposable) before spending time/cost sending a verification email.
Further reading
- Reference implementation: harshil1712/rwsdk-turnstile-demo
- Cloudflare Turnstile documentation
- RedwoodSDK documentation


