How to Add Cloudflare Turnstile to a Contact Form

Protect an HTML, React or Next.js contact form with Cloudflare Turnstile and verify every token server-side, even on a static site with no backend.

Nisuform team

6 min read

The Cloudflare logo and the Nisuform logo on dark tiles, joined by a line that fades from Cloudflare orange to Nisuform pink.

Short answer: create a widget in the Cloudflare dashboard, load Cloudflare's script and put <div class="cf-turnstile" data-sitekey="..."> inside your form. Then have something that holds your secret key check every token with Cloudflare's siteverify API. The widget on its own blocks nothing. If your site has no server, post the form to a form backend that does the check, like Nisuform, which verifies Turnstile tokens on every plan.

contact.html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form action="https://api.nisuform.com/s/YOUR_FORM_KEY" method="POST">
  <label for="email">Email</label>
  <input id="email" type="email" name="email" required>

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

  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">
  <div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY"></div>
  <button type="submit">Send</button>
</form>

The widget is only half of Turnstile

Turnstile runs its checks in the visitor's browser and puts a token in a hidden field named cf-turnstile-response. A bot doesn't have to load your page at all. It can post straight to wherever your form sends its data, with or without a token, so the token only counts if whoever receives the form checks it. Cloudflare's docs say it plainly: "The client-side widget alone does not protect your forms."

The check is a POST to https://challenges.cloudflare.com/turnstile/v0/siteverify with your secret key and the token. Each token is valid for 300 seconds and can be verified once. The secret key is what makes this hard on a static site: it can't go in HTML or browser JavaScript, so something on a server has to make the call.

That leaves three options:

  • Your own server or function. A route on your server, or a Cloudflare Worker, Netlify Function or Vercel function, receives the form, calls siteverify, then sends the email or saves the message.
  • A form backend. The form posts to the service, which holds your secret key and calls siteverify before it stores anything. Nisuform does this on every plan.
  • Nothing. The widget shows up and spam keeps coming, because nothing reads the token.

Turnstile or reCAPTCHA?

Cloudflare TurnstileGoogle reCAPTCHA
Free usageUnlimited challenges, up to 20 widgets10,000 checks a month across your Google Cloud organization
What visitors seeA small widget, a checkbox only when needed, or nothingAn "I'm not a robot" checkbox (v2), or nothing with a score (v3)
AccountCloudflare, with no need to move your site or DNSA Google Cloud project, with billing above the free tier
Token fieldcf-turnstile-responseg-recaptcha-response

Both work with Nisuform. Turnstile is the easier start. If you're moving a form that already has reCAPTCHA keys, keep them and follow the reCAPTCHA setup instead.

Add Turnstile to an HTML form

  1. Create a Turnstile widget

    In the Cloudflare dashboard, open Turnstile and add a widget. Give it a name, add every hostname the form runs on, like example.com and www.example.com, and keep the Managed mode. Copy the site key and the secret key.

  2. Add the keys to your form

    Create a form in Nisuform. In its Settings, Spam protection, turn on Require a CAPTCHA, choose Cloudflare Turnstile, paste both keys and save. The secret key is write only, so nobody can read it back out of the dashboard.

  3. Put the widget inside the form

    Copy the endpoint from the form's Setup tab into the form's action, and the site key into data-sitekey, as in the code at the top. The widget has to be inside the <form> element, or its hidden field won't be sent. The styled embed on the Setup tab adds the widget for you once Turnstile is on.

  4. Send a test

    Open the page, let the widget finish, wait two seconds and send. The message should reach your Nisuform inbox and your email. Submit once with the widget removed from the page, and the request should fail with captcha_failed.

Pick a widget mode

The mode is part of the widget's settings in the Cloudflare dashboard, and the page code is the same for all three:

  • Managed decides per visitor. Most people see the widget complete on its own, and visitors who look risky are asked to tick a box.
  • Non-interactive shows the widget with a loading spinner while the checks run, and never asks for a click.
  • Invisible shows nothing at all.

Managed is the right default for a contact form, because it can still ask a suspicious visitor to tick a box. Non-interactive and Invisible never can.

Turnstile in React and Next.js

Cloudflare's script looks for .cf-turnstile elements once, when it loads. In React the form usually mounts after that, so the widget never appears. Render it yourself when the component mounts, and load the script if the page doesn't have it yet:

ContactForm.jsx
'use client'

import { useEffect, useRef, useState } from 'react'

const SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js'

export function ContactForm() {
  const widget = useRef(null)
  const [status, setStatus] = useState('idle')

  useEffect(() => {
    if (window.turnstile) {
      if (!widget.current.childElementCount) window.turnstile.render(widget.current)
    } else if (!document.querySelector(`script[src="${SCRIPT}"]`)) {
      const script = document.createElement('script')
      script.src = SCRIPT
      script.async = true
      document.head.append(script)
    }
  }, [])

  async function onSubmit(event) {
    event.preventDefault()
    setStatus('sending')
    const response = await fetch('https://api.nisuform.com/s/YOUR_FORM_KEY', {
      method: 'POST',
      headers: { Accept: 'application/json' },
      body: new FormData(event.currentTarget),
    })
    if (response.ok) {
      setStatus('sent')
    } else {
      window.turnstile?.reset(widget.current)
      setStatus('error')
    }
  }

  if (status === 'sent') return <p role="status">Thanks! We got your message.</p>

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: 'none' }} />
      <div ref={widget} className="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY" />
      <button type="submit" disabled={status === 'sending'}>Send</button>
      {status === 'error' && <p role="alert">Something went wrong. Please try again.</p>}
    </form>
  )
}

new FormData(event.currentTarget) picks up the widget's hidden field along with your inputs, so there's no token handling to write. The childElementCount check stops a second render when React runs the effect twice in development.

The 'use client' line is for the Next.js App Router, where components are server components by default. Leave it out in plain React and Vite. The Setup tab generates the same form for Vue, Svelte and Astro, with your fields and site key filled in.

Reset the widget after a failed submit

Tokens are single use. If the first attempt fails, for example after a dropped connection or a validation error, the token is already spent and the retry will fail with captcha_failed too. Call turnstile.reset() after any failed request so the widget fetches a new one, as the React example does. A visitor who leaves the tab open for more than five minutes hits the same problem, and the widget refreshes itself in that case.

Test with Cloudflare's dummy keys

Cloudflare publishes keys that always pass or always fail, so you can test without solving challenges:

KeyValueResult
Site key1x00000000000000000000AAAlways passes, visible widget
Site key2x00000000000000000000ABAlways fails, visible widget
Site key3x00000000000000000000FFForces an interactive challenge
Secret key1x0000000000000000000000000000000AAAlways passes verification
Secret key2x0000000000000000000000000000000AAAlways fails verification

Put the test keys on a separate test form in Nisuform, never on the live one, or every bot passes.

When every submission fails with captcha_failed

Nisuform answers 403 with captcha_failed when the token is missing, expired, already used or rejected by Cloudflare. Nothing is stored, so the visitor can try again. If it happens to every submission, check these in order:

  • The widget is inside the <form> element, not next to it.
  • The site key on the page and the keys in the form's settings come from the same widget.
  • The page's hostname is in the widget's hostname list in Cloudflare.
  • With fetch, you send the whole FormData. Building a JSON body from your own state drops the token unless you add cf-turnstile-response yourself.
  • Your Content Security Policy, if you have one, allows challenges.cloudflare.com.

Turnstile goes on top of the free checks

Every Nisuform form already runs a honeypot, a fill-time check, rate limits, and link and duplicate checks, and none of them ask the visitor to do anything. Bots caught by the honeypot or the fill-time check are dropped, and messages flagged by the link and duplicate checks go to a spam folder that doesn't count toward your monthly limit. Turn on Turnstile for forms that still get spam, like a public signup form. A low-traffic contact form usually needs only the default checks.

Frequently asked questions

Is Cloudflare Turnstile free?

Yes. The free plan includes up to 20 widgets, 10 hostnames per widget and unlimited challenges. Your site doesn't have to use Cloudflare's CDN or DNS, because the widget loads from challenges.cloudflare.com on any website.

Does Turnstile work on a static site?

The widget does, but the token it produces has to be checked with your secret key through Cloudflare's siteverify API, and the secret can't go in browser code. On a static site, send the form to a form backend that does the check for you. Nisuform verifies Turnstile tokens on every plan, including Free.

How long is a Turnstile token valid?

Each token is valid for 300 seconds and can be verified once. If a visitor leaves the form open for longer, or a submit fails and they try again, the widget needs a fresh token. Call turnstile.reset() after a failed submit.

Do visitors have to click anything?

It depends on the widget mode. Managed shows a checkbox only when Cloudflare thinks the visitor might be a bot. Non-interactive shows a small widget with a spinner and never asks for a click. Invisible shows nothing at all.

Should I use Turnstile or reCAPTCHA?

Turnstile, unless you already have reCAPTCHA keys you want to keep. Turnstile's free plan has no cap on challenges and needs only a Cloudflare account. reCAPTCHA's free tier covers 10,000 checks a month across your Google Cloud organization. Nisuform verifies both.

How do I test Turnstile without being blocked?

Use Cloudflare's test keys in a separate test form. The site key 1x00000000000000000000AA always passes with a visible widget, and the secret key 1x0000000000000000000000000000000AA always passes verification. Never put test keys on your live form.

Start collecting form submissions

Set up your forms with built-in spam protection in minutes for free.

Jane Smith

jane@smith.design

Live

12.4k

Submissions

24

Forms

0

Spam