Stop contact form spam7 min read
How to add Cloudflare Turnstile to an HTML form
Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
Add Turnstile's script and a cf-turnstile element inside your form; the widget puts a token in a hidden cf-turnstile-response field. Then send that token and your secret key to Cloudflare's siteverify endpoint from a server, and accept the post only if it answers success. Without a server, let your form backend verify it, or use Ikiform's managed check, which needs no keys at all.
What's different with Turnstile
Turnstile has two halves, and a plain HTML form only has room for one. The widget runs in the browser with your public site key and produces a token. The check runs on a server with your secret key, which must never reach the page. A form that shows the widget but never verifies the token is decoration: a bot posts straight to your endpoint and skips the widget entirely.
So the real question for an HTML form is where the secret lives. This page covers three answers: your own small server, a form backend that holds your keys, and a managed check where nobody holds keys but the backend.
- The Turnstile widget runs in the browser with your public site key. Most visitors never see it do anything.
- It issues a token and puts it in a hidden cf-turnstile-response input inside the form.
- The form posts, and the token travels with the other fields.
- Your server sends the secret key, the token and the visitor's IP to Cloudflare's siteverify endpoint.
- It checks that the answer says success and that the hostname and action are the ones you expect.
- If everything matches, the post is accepted. If anything is off, it is refused.
Add the widget
Create a widget in the Cloudflare dashboard under Turnstile, list the hostnames your form runs on, and copy the site key. Then add the script and a cf-turnstile element inside the form. The element must be inside the <form>, or the hidden token field ends up outside it and never gets posted.
<form action="https://example.com/api/contact" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
<button>Send message</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>data-action="contact"labels the widget. Siteverify returns it, so your server can refuse a token minted for a different form.- The script must load from Cloudflare's own URL. Cloudflare says a proxied or self-hosted copy breaks the widget.
- If your site sends a Content Security Policy, allow
https://challenges.cloudflare.comin bothscript-srcandframe-src. - At no cost, Cloudflare allows up to 20 widgets, each on up to 10 hostnames, so list staging hosts too.
Verify the token on your own server
Any server can verify a token: one POST to siteverify with the secret and the token. Here it is as a Cloudflare Worker that a static form posts to. It checks the token, the hostname and the action, drops the token, and forwards the fields to Ikiform with a secret key. Swap the forward for anything else that stores or emails the message.
interface Env {
TURNSTILE_SECRET_KEY: string;
IKIFORM_SECRET_KEY: string;
}
interface Verdict {
success: boolean;
hostname?: string;
action?: string;
"error-codes"?: string[];
}
const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await request.formData();
const check = await fetch(SITEVERIFY, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY,
response: String(form.get("cf-turnstile-response") ?? ""),
remoteip: request.headers.get("CF-Connecting-IP"),
idempotency_key: crypto.randomUUID(),
}),
});
const verdict = (await check.json()) as Verdict;
const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
if (!verdict.success || !expected) {
console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
return Response.redirect(`${SITE}/contact?error=check`, 303);
}
form.delete("cf-turnstile-response");
const sent = await fetch(ENDPOINT, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("Origin") ?? SITE,
},
body: form,
});
return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
},
};- Siteverify accepts JSON or form-encoded bodies and always answers JSON.
remoteipis optional;idempotency_keylets you retry a check safely after a network error. - Check
hostnameandaction, not onlysuccess. A token minted on someone else's page with your site key also sayssuccess. - Store both secrets with
wrangler secret put, never inwrangler.toml. - The Worker answers with a
303redirect, so the plain form needs no JavaScript. Point the form'sactionat the Worker's route. - It passes the page's
Originheader on, because a server-side fetch sends none and Ikiform reads it to recognise freelocalhosttests.
To show the error on the form page after a failed check, read the query string the Worker sets:
<p id="form-error" role="alert" hidden></p>
<script>
const reason = new URLSearchParams(location.search).get("error");
const banner = document.getElementById("form-error");
if (reason && banner) {
banner.textContent =
reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
banner.hidden = false;
}
</script>IKIFORM_SECRET_KEY in server-only environment variables.Let Ikiform verify it
If you don't want to run a server at all, the form can post straight to Ikiform and Ikiform does the siteverify call. Each form's Spam check setting decides how.
| Route | Keys you manage | Widget on your page | Server of your own |
|---|---|---|---|
| Your own server or Worker, above | Site key and secret key | Yes, yours | Yes |
| Ikiform with Your own Turnstile keys | Site key and secret key, pasted into Ikiform | Yes, yours | No |
| Ikiform with Ikiform | None | No. A check page on ikiform.com, or a small frame | No |
With Your own Turnstile keys, paste the site key and secret key into the form's settings. The secret is encrypted at rest. Keep the same widget markup, but point the form at your Ikiform endpoint. Ikiform verifies cf-turnstile-response and checks the token's hostname against the form's Allowed domains. A post without a valid token gets 403 captcha_failed, even with a secret API key.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
<button>Send message</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>With Ikiform, you add nothing to the page. A plain HTML post without a token gets a short Checking you're human page on ikiform.com that runs Turnstile and sends the fields on. A fetch post gets 403 challenge_required instead; call challenge() from @ikiform/sdk to get a token first. No Cloudflare account is involved. The spam docs show both flows.
If Cloudflare can't be reached, Ikiform doesn't drop the post: it stores it as spam with the reason captcha_unavailable, so you can restore it from the dashboard. Your own server should make the same call deliberately, either failing closed like the Worker above or keeping the post for review.
Test keys
Cloudflare publishes dummy keys that work on any hostname, including localhost. Use them while you build, then swap in your real keys.
| Key | Value | Behaviour |
|---|---|---|
| Site key | 1x00000000000000000000AA | Always passes, visible widget |
| Secret key | 1x0000000000000000000000000000000AA | Siteverify always answers success |
| Secret key | 2x0000000000000000000000000000000AA | Siteverify always answers failure |
Test tokens don't carry your real hostname, so the Worker's hostname check refuses them. Relax that check only in your development environment, never in production.
Check it works
- Submit the form normally. The post is accepted and the widget showed nothing, or a single checkbox.
- Remove the
cf-turnstileelement and submit. The post is refused: the Worker redirects with?error=check, or Ikiform answers403 captcha_failed. - Submit, press Back and submit the same page again. The reused token fails, which shows the server really verifies it.
- Open Submissions in the dashboard. The new submission is there with every field you sent.
- Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.
Gotchas
- One token, one try. After a failed submit handled with
fetch, callturnstile.reset()for a fresh token. A full page reload gets one automatically. - Slow typists. Tokens expire after 300 seconds. The widget's
data-refresh-expiredsetting decides whether it fetches a new one on its own or waits for a click. - www and the apex domain.
www.example.comandexample.comare different hostnames to siteverify. Compare against the one your form really runs on, or allow both. - Single-page navigation. The implicit script renders widgets once, when it loads. On a client-side route change, render explicitly with
?render=explicitandturnstile.render(). - Server posts with Ikiform's own keys. If your server forwards to Ikiform with a secret key, Ikiform skips its managed check. Verify the token yourself first, as the Worker does.
Questions
The widget runs anywhere, but the token must be verified with your secret key on a server. Without one, post the form to a form backend that verifies it for you, or use Ikiform's managed check, which needs no keys.
Cloudflare offers it at no cost for up to 20 widgets with up to 10 hostnames each, and its plans page lists unlimited challenges. Check Cloudflare's current plans page before you rely on that.
The token is older than 300 seconds or was already verified once. Render a fresh widget, or reset it, after every submit attempt.
No. Set the form's Spam check to Ikiform and Ikiform runs Turnstile on ikiform.com. You only need an account to use your own keys and your own widget.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.