Netlify Forms only supports reCAPTCHA v2 (data-netlify-recaptcha="true"). There is no Turnstile option. To use Cloudflare Turnstile you drop Netlify Forms for that form and verify the token yourself in a Netlify Function, which then emails you the submission.

Works with any framework. Nuxt notes are at the end.

1. Create the Turnstile widget

Your DNS does not need to be on Cloudflare.

We never opened the dashboard for this. cf, Cloudflare's new CLI for the whole public API, created the widget in one command, and that was genuinely cool:

npm install --global cf
cf auth login
cf turnstile widgets create --body '{"name":"contact-form","mode":"invisible","domains":["example.com","www.example.com","localhost"]}'

The JSON it returns has both keys. The widget mode goes in --body because cf already uses --mode as a global flag, so there is no separate flag for it. Add --dry-run first if you want to see the exact API request before it runs.

Prefer clicking? Cloudflare dashboard, Turnstile, Add widget gets you the same widget. Either way:

  • Mode: Invisible if you never want a checkbox. Managed if you're fine with one occasionally.
  • Hostnames: your domain, www. if you use it, and localhost (any port works).

Copy the site key (public, goes in your page) and the secret key (server only).

2. Add the function

Create netlify/functions/contact.mts. Netlify deploys anything in that folder automatically, even on a static site. This one uses Resend to send the email. Swap that fetch for any other sender.

const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ALLOWED_HOSTNAMES = new Set(["example.com", "www.example.com"]);

type TurnstileResult = {
  success: boolean;
  hostname?: string;
  "error-codes"?: string[];
};

function json(status: number, body: Record<string, unknown>): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
  });
}

function escapeHtml(value: string): string {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function field(form: FormData, name: string, maxLength: number): string {
  return String(form.get(name) ?? "").trim().slice(0, maxLength);
}

async function verifyTurnstile(token: string, secret: string, ip?: string): Promise<TurnstileResult> {
  const params = new URLSearchParams({ secret, response: token });
  if (ip) {
    params.set("remoteip", ip);
  }
  const response = await fetch(SITEVERIFY_URL, { method: "POST", body: params });
  if (!response.ok) {
    return { success: false, "error-codes": [`siteverify-http-${response.status}`] };
  }
  return (await response.json()) as TurnstileResult;
}

export default async (req: Request, context: { ip?: string }) => {
  if (req.method !== "POST") {
    return json(405, { ok: false, error: "Method not allowed." });
  }

  const secret = process.env.TURNSTILE_SECRET_KEY;
  const resendKey = process.env.RESEND_API_KEY;
  const to = process.env.CONTACT_TO_EMAIL;
  if (!secret || !resendKey || !to) {
    console.error("[contact] Missing TURNSTILE_SECRET_KEY, RESEND_API_KEY or CONTACT_TO_EMAIL");
    return json(500, { ok: false, error: "The contact form is not configured." });
  }

  let form: FormData;
  try {
    form = await req.formData();
  } catch {
    return json(400, { ok: false, error: "Invalid submission." });
  }

  // Honeypot: report success so the bot learns nothing.
  if (field(form, "bot-field", 200)) {
    return json(200, { ok: true });
  }

  const token = field(form, "cf-turnstile-response", 2048);
  if (!token) {
    return json(400, { ok: false, error: "Verification did not complete. Please try again." });
  }

  const result = await verifyTurnstile(token, secret, context?.ip);
  if (!result.success) {
    console.warn("[contact] Turnstile rejected:", result["error-codes"]);
    return json(403, { ok: false, error: "Verification failed. Please refresh the page and try again." });
  }

  // `netlify dev` sets NETLIFY_DEV, so localhost tokens only pass locally.
  const allowLocalhost = process.env.NETLIFY_DEV === "true" && result.hostname === "localhost";
  if (result.hostname && !allowLocalhost && !ALLOWED_HOSTNAMES.has(result.hostname)) {
    return json(403, { ok: false, error: "Verification failed." });
  }

  const name = field(form, "name", 200);
  const email = field(form, "email", 320);
  const message = field(form, "message", 10000);
  if (name.length < 2 || !EMAIL_RE.test(email) || message.length < 10) {
    return json(400, { ok: false, error: "Please complete all required fields correctly." });
  }

  const sent = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${resendKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "Contact Form <contact@example.com>",
      to: [to],
      reply_to: email,
      subject: `Contact form: ${name.replace(/[\r\n]+/g, " ")}`,
      text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
      html: `<p><strong>Name:</strong> ${escapeHtml(name)}<br><strong>Email:</strong> ${escapeHtml(email)}</p>
             <p style="white-space:pre-wrap">${escapeHtml(message)}</p>`,
    }),
  });

  if (!sent.ok) {
    console.error("[contact] Resend failed:", sent.status, await sent.text());
    return json(502, { ok: false, error: "Your message could not be sent. Please try again shortly." });
  }

  return json(200, { ok: true });
};

Change ALLOWED_HOSTNAMES and the from address to yours. The from domain must be verified in Resend.

3. Set the environment variables

Netlify, Site configuration, Environment variables:

  • TURNSTILE_SECRET_KEY
  • RESEND_API_KEY
  • CONTACT_TO_EMAIL

Include the Local development context if you want to test with netlify dev.

4. Update the form

Remove data-netlify, data-netlify-recaptcha and netlify-honeypot from the form. If you leave them, Netlify keeps accepting direct POSTs that never touch Turnstile.

Then render the widget and post to the function:

<form id="contact">
  <input name="name" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <input name="bot-field" style="display:none" tabindex="-1" autocomplete="off" />
  <div id="turnstile"></div>
  <button type="submit">Send</button>
  <p id="status"></p>
</form>

<script>
  let token = "";
  let widgetId = null;

  window.onTurnstileLoad = () => {
    widgetId = turnstile.render("#turnstile", {
      sitekey: "YOUR_SITE_KEY",
      action: "contact",
      "refresh-expired": "auto",
      callback: (t) => {
        token = t;
      },
      "expired-callback": () => {
        token = "";
      },
      "error-callback": () => {
        token = "";
      },
    });
  };

  function waitForToken(timeoutMs = 10000) {
    const started = Date.now();
    return new Promise((resolve) => {
      const check = () => {
        if (token) {
          resolve(token);
        } else if (Date.now() - started > timeoutMs) {
          resolve("");
        } else {
          setTimeout(check, 200);
        }
      };
      check();
    });
  }

  document.getElementById("contact").addEventListener("submit", async (e) => {
    e.preventDefault();
    const status = document.getElementById("status");

    const t = await waitForToken();
    if (!t) {
      status.textContent = "Spam protection could not verify this browser. Refresh and try again.";
      return;
    }

    const body = new URLSearchParams(new FormData(e.target));
    body.set("cf-turnstile-response", t);

    try {
      const res = await fetch("/.netlify/functions/contact", { method: "POST", body });
      const result = await res.json().catch(() => ({}));
      if (res.ok && result.ok) {
        status.textContent = "Thanks, your message is on its way.";
        e.target.reset();
      } else {
        status.textContent = typeof result.error === "string" ? result.error : "Something went wrong. Please try again.";
      }
    } finally {
      token = "";
      turnstile.reset(widgetId);
    }
  });
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad" async defer></script>

5. Test locally

nuxt dev, vite and friends don't run Netlify Functions, so the POST 404s. Use the Netlify CLI instead:

npx netlify-cli link
npx netlify-cli dev

link lets dev pull your environment variables from Netlify, so you don't need a local .env. Open http://localhost:8888 in your normal browser and send a message.

Don't test with an automated browser (Playwright and the like). Turnstile detects it and returns XXXX.DUMMY.TOKEN.XXXX or nothing.

6. Check production

After deploying, a forged token should get a 403:

curl -X POST -d "name=x" https://example.com/.netlify/functions/contact
# {"ok":false,"error":"Verification did not complete. Please try again."}

curl -X POST \
  -d "name=Probe&email=a@b.co&message=forged token probe&cf-turnstile-response=XXXX.DUMMY.TOKEN.XXXX" \
  https://example.com/.netlify/functions/contact
# HTTP 403 {"ok":false,"error":"Verification failed. Please refresh the page and try again."}

If the second call returns success, your function isn't calling siteverify.

Nuxt notes

Only the site key goes in runtimeConfig.public. Everything there ships to the browser, so a secret in there is a published secret. The function reads the secret from process.env.

runtimeConfig: {
  public: {
    TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY || "",
  },
},

netlify dev needs to know where Nuxt runs. Add a netlify.toml:

[dev]
  command = "npm run dev"
  targetPort = 3000
  port = 8888
  framework = "#custom"

A server/api route won't work on a static nuxt generate deploy, which is why the handler is a Netlify Function.

Gotchas

  • Tokens are single use and last 300 seconds. Call turnstile.reset() after every submit, including failures, or retries always fail.
  • Check the hostname. Your widget allows localhost, so anyone can mint a valid token locally. The function rejects tokens from any other hostname except under netlify dev.
  • Wait for the token. A fast submitter can click Send before the invisible widget finishes. The client waits up to 10 seconds, then tells the user instead of failing silently.
  • Only show your own error strings. A platform 404 can return JSON with error: true, and your error box will literally say "true".
  • You lose the Netlify Forms tab. Submissions arrive by email instead. Resend's log is your record.