Security

How to add Cloudflare Turnstile to a contact form

October 27, 20268 min read

Your contact form has been fine for months. A honeypot field catches the lazy bots, and rate limiting handles the bursts. Then a new kind of spam starts arriving: submissions that skip the honeypot, arrive at a human pace, and fill every field with something plausible. Something is running a real browser against your form.

That's the moment a challenge earns its place. The problem with CAPTCHA argues for exhausting the cheaper defenses first, and if you've done that, Cloudflare Turnstile is the lowest-friction challenge available. Most visitors never see a puzzle.

The catch is that Turnstile has two halves, and most broken setups only implement one. This guide covers both.

How Turnstile works

  1. In the browser, the Turnstile widget runs a challenge and produces a token.
  2. On your server, you send that token to Cloudflare's Siteverify API, which tells you whether it's genuine.

The token is only a claim until your server checks it. A bot can skip your JavaScript entirely and post straight to your endpoint with any value in the token field, so a widget with no server-side verification is decoration.

Tokens also have rules you need to design around. Per Cloudflare's documentation, each token is valid for 300 seconds and can only be validated once. Verifying the same token twice fails with a timeout-or-duplicate error.

Create a widget

In the Cloudflare dashboard, open Turnstile and add a widget. You'll choose:

  • Hostnames: the domains where the widget is allowed to run. Add every domain the form lives on.
  • Widget mode: Cloudflare recommends Managed, which picks between a non-interactive check and a checkbox based on how risky the visitor looks. Non-Interactive always shows a spinner but never asks for a click, and Invisible runs with no visible widget at all.

You get two keys. The site key is public and goes in your HTML. The secret key is for your server only. Never put it in client-side code, where anyone can read it.

Render the widget and submit the form

Turnstile can render itself automatically on any element with the cf-turnstile class. For forms you submit with JavaScript, render it explicitly instead. Explicit rendering gives you a widget ID, which you need to reset the widget after every submission.

<form id="contact-form" action="/api/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" type="text" required />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>

  <div id="turnstile-container"></div>

  <button type="submit">Send message</button>
  <p id="form-status" role="status"></p>
</form>

<script>
  let widgetId;

  // Turnstile calls this once its script has loaded (see onload= below).
  function onTurnstileLoad() {
    widgetId = turnstile.render("#turnstile-container", {
      sitekey: "YOUR_SITE_KEY",
    });
  }
</script>
<script
  src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad"
  defer
></script>

Then the submit handler:

<script>
  const form = document.getElementById("contact-form");
  const statusEl = document.getElementById("form-status");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();

    const token = widgetId !== undefined ? turnstile.getResponse(widgetId) : "";
    if (!token) {
      statusEl.textContent = "Please wait for the security check to finish.";
      return;
    }

    const data = new URLSearchParams(new FormData(form));
    data.set("cf-turnstile-response", token);

    try {
      const res = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result = await res.json().catch(() => ({}));

      if (res.ok) {
        form.reset();
        statusEl.textContent = "Thanks! Your message was sent.";
      } else {
        statusEl.textContent =
          result.error ?? "Something went wrong. Please try again.";
      }
    } catch {
      statusEl.textContent =
        "Network error. Check your connection and try again.";
    } finally {
      // Tokens are single-use, so every attempt needs a fresh one.
      turnstile.reset(widgetId);
    }
  });
</script>

The finally block is the line most setups are missing. Without it, the first submission works, and the second one, after a typo or a failed send, posts a token your server already spent. Siteverify rejects it with timeout-or-duplicate, and the visitor sees a security error they did nothing to cause. Resetting after every attempt, success or failure, gives the next one a fresh token.

The handler also reads the token with turnstile.getResponse() and adds it to the request itself. That way the field name your server expects, cf-turnstile-response, doesn't depend on how the widget is placed in the page.

Note

If your site sends a Content Security Policy header, allow challenges.cloudflare.com for scripts and frames, or the widget won't load.

Verify the token on your server

Siteverify takes your secret key and the token, and returns JSON with a success flag. Here's a verifier for Node.js 18 or newer, which has fetch built in:

// verify-turnstile.js
const SITEVERIFY_URL =
  "https://challenges.cloudflare.com/turnstile/v0/siteverify";

export async function verifyTurnstile(token, { remoteip, hostname } = {}) {
  // Real tokens are at most 2048 characters; anything else isn't worth a call.
  if (typeof token !== "string" || !token || token.length > 2048) {
    return { ok: false, reason: "missing-input-response" };
  }

  let result;
  try {
    const res = await fetch(SITEVERIFY_URL, {
      method: "POST",
      body: new URLSearchParams({
        secret: process.env.TURNSTILE_SECRET_KEY,
        response: token,
        ...(remoteip && { remoteip }),
      }),
    });
    result = await res.json();
  } catch {
    return { ok: false, reason: "siteverify-unreachable" }; // fail closed
  }

  if (!result.success) {
    return { ok: false, reason: result["error-codes"]?.join(",") || "failed" };
  }
  // Cloudflare recommends confirming the challenge ran on your own domain.
  if (hostname && result.hostname !== hostname) {
    return { ok: false, reason: "hostname-mismatch" };
  }
  return { ok: true };
}

Three choices here are deliberate.

It fails closed. If Cloudflare can't be reached, the submission is rejected rather than waved through. An attacker who can make verification fail shouldn't be able to make it pass.

It checks the hostname. Siteverify reports the hostname where the challenge was solved, and Cloudflare's documentation recommends validating it. The widget's hostname list already limits where your site key can run; checking the hostname on the server is a cheap second guarantee that the token came from your own pages.

It returns a reason. The error-codes array tells you why a token failed. Log it; it's the difference between "bots are being blocked" and "every real visitor is being blocked."

Then call it before doing anything else in your route:

// server.js (Express)
app.post("/api/contact", async (req, res) => {
  const body = req.body ?? {};
  const check = await verifyTurnstile(body["cf-turnstile-response"], {
    remoteip: req.ip,
    hostname: process.env.TURNSTILE_HOSTNAME, // unset in local dev
  });
  if (!check.ok) {
    console.warn("Turnstile rejected the submission:", check.reason);
    return res
      .status(403)
      .json({ ok: false, error: "Security check failed. Please try again." });
  }

  // Validate the fields and send the email here, as in the Node.js guide.
  res.json({ ok: true });
});

If you're building the rest of that route, How to build a Node.js contact form that sends email covers the validation, rate limiting, and SMTP setup this snippet leaves out. Turnstile works best alongside those defenses, not instead of them.

Test it without real traffic

Cloudflare publishes dummy keys that behave predictably, so you can exercise every path locally:

KeyValueBehavior
Site key1x00000000000000000000AAVisible widget that always passes
Site key2x00000000000000000000ABVisible widget that always fails
Site key3x00000000000000000000FFForces an interactive challenge
Secret key1x0000000000000000000000000000000AAAlways passes validation
Secret key2x0000000000000000000000000000000AAAlways fails validation
Secret key3x0000000000000000000000000000000AAFails with timeout-or-duplicate

The dummy site keys produce the token XXXX.DUMMY.TOKEN.XXXX. One thing to know: validation with a dummy secret reports the hostname as example.com, whatever page you're testing from. Leave TURNSTILE_HOSTNAME unset locally, or every test fails the hostname check.

The third secret key is the useful one. It reproduces the double-submit bug from earlier, so you can confirm your error message makes sense to a visitor.

Common Turnstile mistakes

SymptomCauseFix
Bots still get throughThe token is never verified on the serverCall Siteverify before processing the submission
Second submit fails with timeout-or-duplicateThe same token was sent twiceReset the widget after every attempt
Submits fail after the page sits openThe token expired (300 seconds)Reset and let the widget issue a new token
Widget shows an error instead of loadingThe domain isn't in the widget's hostname list (error 110200)Add the domain under the widget's hostnames in Cloudflare
Every submission fails locallyHostname pinning with dummy keysLeave hostname pinning off in development

Using Turnstile with Formtorch

If your form posts to Formtorch, you skip the server half entirely. Turnstile is available on every plan, including Free.

Open your form in the dashboard, go to Settings → CAPTCHA, turn it on, and paste in your site key and secret key. Add the widget to your form as shown above, point the form at https://formtorch.com/f/YOUR_FORM_ID, and Formtorch verifies the cf-turnstile-response token on every submission before storing it. Submissions with a missing or invalid token are rejected with a 400. The widget still needs resetting after each attempt when you submit with JavaScript, for the same single-use reason.

The Turnstile setup guide walks through the dashboard steps with screenshots. If the form also accepts files, send the token with the upload request instead; How file uploads work in HTML forms shows that flow.

Wrapping up

Turnstile is two jobs. The widget produces a token, and your server proves it's real: verify every token with Siteverify, fail closed when you can't, pin your hostname, and reset the widget after every submission so no token is ever sent twice.

Get those right, and Turnstile becomes the quiet last layer in a stack that already includes a honeypot and rate limiting. How to prevent spam in contact forms covers how those layers fit together.

Want Turnstile without writing the verifier?

Paste your keys into a Formtorch form and every submission is verified server-side, on every plan.

Related posts
The problem with CAPTCHA
The problem with CAPTCHA
SecurityJuly 2, 20266 min read
Best ways to secure a form endpoint
Best ways to secure a form endpoint
SecurityJanuary 21, 20267 min read