Learn · Security
Cloudflare Turnstile on a contact form
Turnstile is Cloudflare's CAPTCHA replacement. A script on your page runs a challenge in the visitor's browser and produces a token; your server sends that token to Cloudflare's siteverify API and only accepts the submission if Cloudflare says it is valid. Most visitors never click anything.
Cloudflare's docs are explicit that the widget alone does not protect a form. Everything that matters happens in the server-side check.
Widget modes
The mode is chosen per widget in the Cloudflare dashboard:
Separately, the appearance option controls when the widget is shown: always (default), execute, or interaction-only, which keeps it hidden unless the visitor has to interact.
- Managed (Cloudflare's recommendation): picks between a non-interactive check and a checkbox based on how risky the visitor looks.
- Non-interactive: a visible widget with a spinner that never asks the visitor to do anything.
- Invisible: no widget at all; the challenge runs in the background.
Client side
Implicit rendering is two lines. Inside a form, the widget adds a hidden input named cf-turnstile-response holding the token, so both a classic submission and new FormData(form) pick it up without extra code.
html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form action="/contact" method="post">
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button>Send</button>
</form>Server side: siteverify
POST the secret key and the token to siteverify and accept the submission only when success is true. Do it before anything is stored or emailed.
js
async function verifyTurnstile(token, secret) {
if (!token || token.length > 2048) return false; // documented max length
try {
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: new URLSearchParams({ secret, response: token }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return false;
const data = await res.json();
// Optional hardening: compare data.hostname and data.action to what you expect.
return data.success === true;
} catch {
return false; // fail closed: no verdict, no submission
}
}Failing closed means a Cloudflare outage blocks submissions. Failing open means an outage switches your protection off. For a contact form, closed is usually right, because the alternative is a spam flood you only notice afterwards. Whichever you pick, decide it in code rather than letting an unhandled exception decide.
Token rules that cause real bugs
js
const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } });
if (!res.ok) turnstile.reset(); // the old token has been used- A token is valid for 300 seconds. With the default
refresh-expired: auto, the widget fetches a new one when it expires, so a visitor who writes a long message is usually fine; withmanualorneverit is your job. - A token can be verified once. A second siteverify call returns
timeout-or-duplicate. That includes your own retries: if a submission fails after verification (a validation error, a 500), the token is spent. - With
fetch(), callturnstile.reset()after every response that is not a final success, so the next attempt carries a fresh token. With classic posts the page reloads and this happens on its own. - If you need to retry the siteverify call itself after a network error, send the same
idempotency_key(a UUID) with both calls so the retry is not treated as a replay.
Privacy: what Cloudflare sees
Cloudflare is a US company. The widget runs in the visitor's browser and talks to Cloudflare directly, so adding it puts Cloudflare in the data flow of every visitor who loads the form, whether or not they submit it. According to Cloudflare's Turnstile privacy addendum, it processes signals such as the client IP address, TLS fingerprint, User-Agent header, and the sitekey with its origin, for the purpose of detecting bots.
Your server's siteverify call adds less: the token and your secret, plus the visitor's IP only if you pass the optional remoteip parameter. Leaving remoteip out keeps the raw address on your side. Either way, list Cloudflare in your privacy notice and your record of processors, and consider enabling Turnstile only on forms that actually attract spam.
How this works with Formward
Turnstile is off by default. Switch it on in a form's settings and paste your own Turnstile secret key; add the widget with your site key to your form. Formward then verifies cf-turnstile-response with siteverify on every submission before storing anything, and rejects a missing or invalid token with a 403 (it fails closed if Cloudflare cannot be reached). The token is removed from the payload before it is stored or emailed.
Formward does not send the visitor's IP to siteverify by default. Cloudflare is listed as an optional, customer-enabled sub-processor in the sub-processor register. Setup steps are in the spam filtering docs.
Sources
Point a form at an EU endpoint
Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.