On this page
Cloudflare Turnstile is a free, privacy-friendly alternative to traditional CAPTCHAs. Most visitors never see a puzzle — Turnstile runs quick background checks and, at most, asks for a single click. It works on any website, even if you don't use Cloudflare for anything else.
This tutorial adds Turnstile to an HTML form and verifies it properly.
How it works
- The Turnstile widget runs on your page and produces a token.
- The token is added to your form as a hidden field named
cf-turnstile-response. - When the form is submitted, the server sends the token and your secret key to Cloudflare's verification endpoint.
- If Cloudflare says the token is valid, the submission is accepted.
A token is single-use and expires after a few minutes, so it can't be reused by bots.
Step 1: Create a Turnstile widget
- Sign in to the Cloudflare dashboard (a free account is fine).
- Open Turnstile and choose Add widget.
- Give it a name and add the hostnames where your form lives (for example
example.com). Addlocalhosttoo if you'll test locally. - Choose a widget mode — Managed is the usual choice.
- Copy the site key (public) and the secret key (keep it private).
Step 2: Add the widget to your form
Add Cloudflare's script once on the page, and a div with your site key inside the form where the widget should appear:
<form action="https://api.formclick.io/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<!-- Turnstile widget: adds a hidden cf-turnstile-response field -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
That's all the front end needs. Useful options on the div:
| Attribute | Purpose |
|---|---|
data-theme="light" / "dark" / "auto" |
Match your design |
data-size="compact" |
Smaller widget for narrow layouts |
data-appearance="interaction-only" |
Only show the widget if a click is needed |
data-callback="onTurnstileOk" |
Run your own function when a token is ready |
Step 3: Verify the token on the server
The token is meaningless until your server checks it. Never skip this step — a bot can submit your form without ever loading the widget.
Option A: Let Formclick verify it (no server code)
If your form posts to Formclick:
- Open your form in the dashboard and go to Settings → Security.
- Choose Cloudflare Turnstile as the captcha provider.
- Paste your secret key and save.
Formclick now verifies the cf-turnstile-response token on every submission and rejects any that fail. If you use the formclick.min.js script, it also asks people to complete the captcha before sending and resets the widget after each submission. CAPTCHA support is included on paid plans.
<script src="https://cdn.formclick.io/formclick.min.js"></script>
Option B: Verify it on your own server
Send a POST to Cloudflare's siteverify endpoint:
// Node.js / serverless example
async function verifyTurnstile(token, ip) {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
remoteip: ip, // optional
}),
});
const result = await response.json();
return result.success === true;
}
// In your form handler:
const ok = await verifyTurnstile(formData.get('cf-turnstile-response'), clientIp);
if (!ok) return new Response('Spam check failed', { status: 400 });
The response also includes error-codes (useful for debugging) and the hostname the token was issued for — you can check it matches your site.
Using Turnstile with JavaScript submissions
If you submit with fetch(), the hidden field is part of the form, so new FormData(form) includes it automatically. After each submission, reset the widget so the next attempt gets a fresh token:
form.addEventListener('submit', async function (event) {
event.preventDefault();
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
// …show success or error…
turnstile.reset(form.querySelector('.cf-turnstile'));
});
Troubleshooting
- Widget doesn't appear: check the script tag loaded and the site key is correct.
- "Invalid domain" error: add the exact hostname (including
localhostfor testing) in the widget settings. - Every submission fails verification: the secret key doesn't match the site key, or the token was used twice (for example, a retry after an error without resetting the widget).
- Works locally, fails in production: your production domain isn't in the hostname list.
Is Turnstile enough on its own?
Turnstile stops automated bots well, but layered protection is best: keep a honeypot field, server-side filtering and rate limiting too. Read How to Protect HTML Forms From Spam, or compare options in CAPTCHA vs reCAPTCHA vs Cloudflare Turnstile.



