Stop contact form spam

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.

Preet SutharFounder, Ikiform

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 life of a Turnstile token, from widget to verdictIn the browser, with the site keyOn a server, with the secret keywidget runsusually invisibletoken issuedhiddencf-turnstile-responseinputform poststoken travels withthe fieldssiteverifysecret + token +visitor IPcheck the answersuccess, hostname,actionaccept the postrefusesubmitall matchanything offThe life of a Turnstile token, from widget to verdictIn the browser, with the site keyOn a server, with the secret keywidget runsusually invisibletoken issuedhiddencf-turnstile-responseinputform poststoken travels withthe fieldssiteverifysecret + token +visitor IPcheck the answersuccess, hostname,actionaccept the postrefusesubmitall matchanything off
  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
<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
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
<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 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.

RouteKeys you manageWidget on your pageServer of your own
Your own server or Worker, aboveSite key and secret keyYes, yoursYes
Ikiform with Your own Turnstile keysSite key and secret key, pasted into IkiformYes, yoursNo
Ikiform with IkiformNoneNo. A check page on ikiform.com, or a small frameNo

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

KeyValueBehaviour
Site key1x00000000000000000000AAAlways passes, visible widget
Secret key1x0000000000000000000000000000000AASiteverify always answers success
Secret key2x0000000000000000000000000000000AASiteverify 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.