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.
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.
- The component posts the fields and the Turnstile token to /api/contact with $fetch.
- The server route reads the client IP from the header named in runtimeConfig.
- It counts the IP's posts in the current minute in Nitro storage. Over five, it throws a 429 with the seconds left.
- Under the limit, it verifies the token with siteverify, checking the action and the host.
- Then it forwards the fields to Ikiform, which stores them and scores the honeypot.
Project setup
app/
components/
ContactForm.vue
composables/
useTurnstile.ts
server/
api/
contact.post.ts
utils/
spam.ts
nuxt.config.ts
.envNUXT_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-forconst 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
runtimeConfigkey can be overridden at runtime by aNUXT_variable:turnstileSecretKeybyNUXT_TURNSTILE_SECRET_KEY,public.turnstileSiteKeybyNUXT_PUBLIC_TURNSTILE_SITE_KEY. Keys outsidepublicnever reach the browser. routeRulessends the CSP only on/contact. Nuxt inlines its payload and hydration scripts, so this policy keeps'unsafe-inline'; the communitynuxt-securitymodule can generate nonces if you need a stricter policy.- The
upstashdriver is mounted only in production and readsUPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENfrom the environment. It needs the@upstash/redispackage, so install it withnpm install @upstash/redis.
Server utilities
Files in server/utils are auto-imported into every server route:
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;
}
}clientIpreads exactly one header, the one your host overwrites. SetNUXT_CLIENT_IP_HEADERtox-forwarded-foron Vercel,x-nf-client-connection-ipon Netlify andcf-connecting-ipon 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.
$fetchsends the siteverify body as JSON, which siteverify accepts. The host check usesgetRequestHostand is skipped in development, because test tokens carry a dummy hostname.
The server route
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.tssuffix makes Nitro answer onlyPOST. Anything else gets405. - The limit runs before
readBody, so a flood never costs a body parse or a siteverify call. - Each failure throws
createError. The JSON body carriesmessage, which the component shows. - The visitor's
Originis passed on, because a server-side fetch sends none and Ikiform reads it to recognise freelocalhosttests. - The honeypot
_gotchagoes along untouched, so Ikiform stores a filled one as spam rather than dropping it.
IKIFORM_SECRET_KEY in server-only environment variables.The composable and the form
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 };
}<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 toucheswindow. - 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
.hpclass 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:
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"}'
doneThe 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 covers the endpoint and keys.
Check it works
- Run
nuxt devwith the test keys and send a message. It appears under Submissions. A workspace without a plan accepts 50localhosttests a month. - Send six messages within a minute. The sixth shows how many seconds are left.
- Set
NUXT_TURNSTILE_SECRET_KEYto the always-fail secret and restart. Every post shows the human-check error. - Request
/contactwithcurl -Iafternuxt build. The response carries yourContent-Security-Policy. - 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
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.