# 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.

Published 2026-10-25 · https://ikiform.com/guides/spam-protection/cloudflare-turnstile

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.

Diagram: The life of a Turnstile token, from widget to verdict

1. The Turnstile widget runs in the browser with your public site key. Most visitors never see it do anything.
2. It issues a token and puts it in a hidden cf-turnstile-response input inside the form.
3. The form posts, and the token travels with the other fields.
4. Your server sends the secret key, the token and the visitor's IP to Cloudflare's siteverify endpoint.
5. It checks that the answer says success and that the hostname and action are the ones you expect.
6. If everything matches, the post is accepted. If anything is off, it is refused.

A token is valid for 300 seconds and can be verified once. A second check of the same token fails with timeout-or-duplicate.

## 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.

`contact.html`:

```html
<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.com` in both `script-src` and `frame-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.

`src/index.ts`:

```ts
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. `remoteip` is optional; `idempotency_key` lets you retry a check safely after a network error.
- Check `hostname` and `action`, not only `success`. A token minted on someone else's page with your site key also says `success`.
- Store both secrets with `wrangler secret put`, never in `wrangler.toml`.
- The Worker answers with a `303` redirect, so the plain form needs no JavaScript. Point the form's `action` at the Worker's route.
- It passes the page's `Origin` header on, because a server-side fetch sends none and Ikiform reads it to recognise free `localhost` tests.

To show the error on the form page after a failed check, read the query string the Worker sets:

`contact.html`:

```html
<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>
```

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `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.

`contact.html`:

```html
<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](https://ikiform.com/docs/spam.md#human-check) 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

1. Submit the form normally. The post is accepted and the widget showed nothing, or a single checkbox.
2. Remove the `cf-turnstile` element and submit. The post is refused: the Worker redirects with `?error=check`, or Ikiform answers `403 captcha_failed`.
3. Submit, press Back and submit the same page again. The reused token fails, which shows the server really verifies it.
4. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
5. 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`, call `turnstile.reset()` for a fresh token. A full page reload gets one automatically.
- **Slow typists.** Tokens expire after 300 seconds. The widget's `data-refresh-expired` setting decides whether it fetches a new one on its own or waits for a click.
- **www and the apex domain.** `www.example.com` and `example.com` are 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=explicit` and `turnstile.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

### Can I use Cloudflare Turnstile without a server?

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.

### Is Cloudflare Turnstile free?

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.

### Why does siteverify say timeout-or-duplicate?

The token is older than 300 seconds or was already verified once. Render a fresh widget, or reset it, after every submit attempt.

### Do I need a Cloudflare account to use Turnstile with Ikiform?

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.

## More guides

- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.
- [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md): Compare reCAPTCHA alternatives for forms, from Turnstile and hCaptcha to proof-of-work and no captcha at all, then switch with a small, safe change.
- [Static sites](https://ikiform.com/guides/spam-protection/static-site.md): Stop spam on a static site form even though it can't keep a secret or count posts, with a honeypot partial, a safe script and server-side checks.
- [Without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md): Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.
- [Honeypot field](https://ikiform.com/guides/spam-protection/honeypot.md): Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.
- [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md): Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
