JB logo
CoffeeyOUTUBE
Blog
PreviousNext

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:

LayerSolvesTool
1. Bot protectionScripted/automated spamCloudflare Turnstile
2. Email validityFake/disposable/unreachable addressesFormat + 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

  1. You create a Turnstile widget in the Cloudflare dashboard and get a Site Key (public) and a Secret Key (private).
  2. You load Turnstile's JavaScript and embed the widget using the Site Key.
  3. Turnstile runs non-interactive JS challenges and ML checks in the background, then produces a token.
  4. Your form sends that token to your server with the form data.
  5. Your server calls Cloudflare's siteverify endpoint with the Secret Key + token.
  6. 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

ModeBehaviourBest for
ManagedNon-interactive most of the time; challenges only suspicious trafficGeneral-purpose default
Non-interactiveVisible but never requires interactionA visible trust badge with zero friction
InvisibleRuns entirely in the backgroundMaximum 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

  1. Cloudflare dashboard → TurnstileAdd widget.
  2. Name it, enter the hostname(s) (localhost for dev, your real domain for prod).
  3. 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 into verifyTurnstileToken() 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.com always 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

  1. On submission, save the entry as unverified.
  2. Send an email with a unique, expiring token link (e.g. https://yoursite.com/verify?token=abc123).
  3. Mark verified only once the link is clicked.
  4. Auto-expire unverified entries after 24–48 hours to keep your list clean.

Option 2 — One-Time Passcode (OTP)

  1. Send a 6-digit code to the submitted email.
  2. Require the user to enter it back before the submission is accepted.
  3. 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