# Nuxt form spam protection with Turnstile

> Protect a Nuxt form from spam: a Nitro server route with Turnstile, a per-IP limit in Nitro storage, runtimeConfig secrets and a CSP set in routeRules.

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

Post the form with `$fetch` to a server route that counts tries per client IP in Nitro storage, verifies the Turnstile token with siteverify and forwards the fields with the honeypot. Keep the secrets in private `runtimeConfig`, set from `NUXT_` environment variables at runtime. Send the CSP for the contact page from `routeRules`, allowing `challenges.cloudflare.com` for the script and its frame.

## What's different in Nuxt

Nuxt's server is Nitro, and Nitro brings two things a spam check needs. Server routes in `server/api` are plain endpoints with `$fetch` already wired to them, and `useStorage()` gives you a key-value store whose backend you choose per environment: memory in development, Redis or another driver in production. That makes the rate limit a config change rather than a dependency.

Two Nuxt habits cause trouble here. `getRequestIP(event, { xForwardedFor: true })` returns the first `X-Forwarded-For` entry, which h3's own docs call client input. And `.env` files are not read in production, so secrets must be set as platform environment variables.

Diagram: A contact post through the Nuxt server route and Nitro storage

1. The component posts the fields and the Turnstile token to /api/contact with $fetch.
2. The server route reads the client IP from the header named in runtimeConfig.
3. It counts the IP's posts in the current minute in Nitro storage. Over five, it throws a 429 with the seconds left.
4. Under the limit, it verifies the token with siteverify, checking the action and the host.
5. Then it forwards the fields to Ikiform, which stores them and scores the honeypot.

In development the storage mount is in memory. In production the same code talks to Redis, because nuxt.config mounts a different driver.

## Project setup

`Files`:

```text
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
```

`.env`:

```bash
NUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-for
```

`nuxt.config.ts`:

```ts
const policy = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export default defineNuxtConfig({
  runtimeConfig: {
    turnstileSecretKey: "",
    ikiformSecretKey: "",
    clientIpHeader: "x-forwarded-for",
    public: {
      turnstileSiteKey: "",
    },
  },
  routeRules: {
    "/contact": { headers: { "Content-Security-Policy": policy } },
  },
  $production: {
    nitro: {
      storage: {
        ratelimit: { driver: "upstash" },
      },
    },
  },
});
```

- Every `runtimeConfig` key can be overridden at runtime by a `NUXT_` variable: `turnstileSecretKey` by `NUXT_TURNSTILE_SECRET_KEY`, `public.turnstileSiteKey` by `NUXT_PUBLIC_TURNSTILE_SITE_KEY`. Keys outside `public` never reach the browser.
- `routeRules` sends the CSP only on `/contact`. Nuxt inlines its payload and hydration scripts, so this policy keeps `'unsafe-inline'`; the community `nuxt-security` module can generate nonces if you need a stricter policy.
- The `upstash` driver is mounted only in production and reads `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` from the environment. It needs the `@upstash/redis` package, so install it with `npm install @upstash/redis`.

## Server utilities

Files in `server/utils` are auto-imported into every server route:

`server/utils/spam.ts`:

```ts
import type { H3Event } from "h3";

const WINDOW_SECONDS = 60;
const LIMIT = 5;

export function clientIp(event: H3Event) {
  const header = useRuntimeConfig(event).clientIpHeader;
  return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}

export async function secondsToWait(ip: string) {
  const storage = useStorage("ratelimit");
  const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
  const key = `contact:${ip}:${window}`;
  const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
  await storage.setItem(key, count);
  if (count <= LIMIT) return 0;
  return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}

export async function isHuman(event: H3Event, token: string, ip: string) {
  if (!token || token.length > 2048) return false;
  try {
    const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
    });
    return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
  } catch {
    return false;
  }
}
```

- `clientIp` reads exactly one header, the one your host overwrites. Set `NUXT_CLIENT_IP_HEADER` to `x-forwarded-for` on Vercel, `x-nf-client-connection-ip` on Netlify and `cf-connecting-ip` on Cloudflare.
- The counter is a fixed window in Nitro storage. Read-then-write isn't atomic, so two posts at the same instant can both slip under the limit; for a contact form that's acceptable. Keys don't expire by themselves, so give the Redis database an eviction policy.
- `$fetch` sends the siteverify body as JSON, which siteverify accepts. The host check uses `getRequestHost` and is skipped in development, because test tokens carry a dummy hostname.

## The server route

`server/api/contact.post.ts`:

```ts
interface ContactBody {
  name?: string;
  email?: string;
  company?: string;
  message?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

export default defineEventHandler(async (event) => {
  const ip = clientIp(event);
  const wait = await secondsToWait(ip);
  if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });

  const body = await readBody<ContactBody>(event);
  const fields = {
    name: body.name?.trim().slice(0, 120) ?? "",
    email: body.email?.trim() ?? "",
    company: body.company?.trim().slice(0, 120) ?? "",
    message: body.message?.trim().slice(0, 5000) ?? "",
    _gotcha: body._gotcha ?? "",
  };
  if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
  if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
    throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
  }

  const origin = getRequestHeader(event, "origin");
  await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: fields,
  }).catch((failure) => {
    throw createError({
      status: failure.status ?? 502,
      message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
    });
  });
  return { sent: true };
});
```

- The `.post.ts` suffix makes Nitro answer only `POST`. Anything else gets `405`.
- The limit runs before `readBody`, so a flood never costs a body parse or a siteverify call.
- Each failure throws `createError`. The JSON body carries `message`, which the component shows.
- The visitor's `Origin` is passed on, because a server-side fetch sends none and Ikiform reads it to recognise free `localhost` tests.
- The honeypot `_gotcha` goes along untouched, so Ikiform stores a filled one as spam rather than dropping it.

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

## The composable and the form

`app/composables/useTurnstile.ts`:

```ts
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<TurnstileApi> | undefined;

function load() {
  loading ??= new Promise<TurnstileApi>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: TurnstileApi }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function useTurnstile(action: string) {
  const { turnstileSiteKey } = useRuntimeConfig().public;
  const box = ref<HTMLElement>();
  const token = ref("");
  let api: TurnstileApi | undefined;
  let widgetId: string | undefined;

  onMounted(async () => {
    api = await load();
    if (!box.value) return;
    widgetId = api.render(box.value, {
      sitekey: turnstileSiteKey,
      action,
      callback: (value) => (token.value = value),
      "expired-callback": () => (token.value = ""),
    });
  });

  onBeforeUnmount(() => {
    if (widgetId) api?.remove(widgetId);
  });

  function reset() {
    token.value = "";
    if (widgetId) api?.reset(widgetId);
  }

  return { box, token, reset };
}
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    reset();
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" type="email" autocomplete="email" required />
    <label for="company">Company <span>(optional)</span></label>
    <input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- The composable runs only in `onMounted`, so the server render never touches `window`.
- The button waits for a token, and `reset()` asks for a new one after every failure, because a token works once and lives 300 seconds.
- This form needs JavaScript. For a version that posts without it, see the next section.
- The `.hp` class hides the honeypot off-screen, as in the [honeypot guide](https://ikiform.com/guides/spam-protection/honeypot.md).

## Test it locally

In development the `ratelimit` mount is in memory, so the count resets when `nuxt dev` restarts. You can watch the limit work without a browser. Posts without a token fail siteverify with `403`, and each one still counts:

`Terminal`:

```bash
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
    -H "Content-Type: application/json" -d '{"email":"test@example.com"}'
done
```

The first five lines print `403` and the sixth prints `429`. Locally no proxy sets the IP header, so every request counts as `unknown`; that's expected, and it's why the header lives in config rather than code.

## Deploying

| Host | NUXT_CLIENT_IP_HEADER | Storage driver |
| --- | --- | --- |
| Vercel | `x-forwarded-for` | `upstash`, from the Vercel Marketplace |
| Netlify | `x-nf-client-connection-ip` | `upstash` or `redis` |
| Cloudflare | `cf-connecting-ip` | `upstash`; Workers KV is eventually consistent, so counts lag |
| Node server | The header your proxy sets | `redis`, or memory on a single instance |

Set every `NUXT_` variable in the host's dashboard. Nuxt reads them when the server starts, so changing a key needs a restart, not a rebuild. Add each production and preview hostname to the Turnstile widget.

## Let Ikiform run the checks

Without the server route, the contact page can be prerendered with the rest of a `nuxt generate` site. Point a plain form at your Ikiform endpoint with `method="post"` and `action` set to your endpoint URL, and delete `server/api/contact.post.ts` and the composable.

Set the form's **Spam check** to **Ikiform**. A post without a token gets a short check page on ikiform.com that runs Turnstile and sends the fields on, so your CSP needs no Cloudflare entries. Choose the Strict **Rate limit**, 2 every 10 minutes per visitor, and list your domain under **Allowed domains**. Ikiform keeps caught spam for 30 days with the reasons it was caught. The [Nuxt setup guide](https://ikiform.com/docs/guides/nuxt.md) covers the endpoint and keys.

## Check it works

1. Run `nuxt dev` with the test keys and send a message. It appears under **Submissions**. A workspace without a plan accepts 50 `localhost` tests a month.
2. Send six messages within a minute. The sixth shows how many seconds are left.
3. Set `NUXT_TURNSTILE_SECRET_KEY` to the always-fail secret and restart. Every post shows the human-check error.
4. Request `/contact` with `curl -I` after `nuxt build`. The response carries your `Content-Security-Policy`.
5. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.

## Gotchas

| Problem | What to do |
| --- | --- |
| Secrets are empty in production | `.env` isn't read at runtime in production. Set `NUXT_TURNSTILE_SECRET_KEY` and the others in the host's environment. |
| The limit resets on every deploy or instance | The storage mount is still in memory. Check that `$production.nitro.storage` mounts a shared driver and its credentials are set. |
| A script gets past the limit by changing headers | You read `getRequestIP` with `xForwardedFor: true` on a host that doesn't overwrite that header. Read the host's own header through `clientIp`. |
| `window is not defined` during server rendering | Turnstile code ran outside `onMounted`. Keep every `window` access inside the composable's lifecycle hooks. |

## Questions

### How do I add Turnstile to a Nuxt form?

Render the widget explicitly in onMounted, keep its token in a ref, and post it with the fields to a server route. The route sends it with your secret key from runtimeConfig to siteverify and continues only on success.

### How do I rate limit a Nuxt server route?

Count requests per client IP with Nitro's useStorage, mounted on Redis or Upstash in production so every instance shares the count. Throw a 429 with createError when the count passes your limit.

### Is getRequestIP safe for rate limiting in Nuxt?

Not with xForwardedFor true on a host that passes the client's own X-Forwarded-For through, because the first entry is whatever the client sent. Read the header your host overwrites instead.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## 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.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): 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.
- [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.
- [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.
- [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.
- [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.
