Stop contact form spam

Stop contact form spam7 min read

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.

Preet SutharFounder, Ikiform

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.

A contact post through the Nuxt server route and Nitro storageContactForm.vue$fetchwiththetokenclientIp(event)headerfromruntimeConfiguseStoragecountthisminutesiteverifyactionandhostIkiformstores,scoreshoneypot429secondsleftinthewindowPOST /api/contactunder 5humanoverA contact post through the Nuxt server route and Nitro storageContactForm.vue$fetch with the tokenclientIp(event)header fromruntimeConfiguseStoragecount thisminutesiteverifyaction and hostIkiformstores, scoreshoneypot429seconds leftin thewindowPOST /api/contactunder 5humanover
  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
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
.env
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
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
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
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 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
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
<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.

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

HostNUXT_CLIENT_IP_HEADERStorage driver
Vercelx-forwarded-forupstash, from the Vercel Marketplace
Netlifyx-nf-client-connection-ipupstash or redis
Cloudflarecf-connecting-ipupstash; Workers KV is eventually consistent, so counts lag
Node serverThe header your proxy setsredis, 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 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

ProblemWhat 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 instanceThe 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 headersYou 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 renderingTurnstile code ran outside onMounted. Keep every window access inside the composable's lifecycle hooks.

Questions

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