Spam im Kontaktformular stoppen7 Min. Lesezeit
Spamschutz für Nuxt-Formulare mit Turnstile
Schütze ein Nuxt-Formular vor Spam: eine Nitro-Server-Route mit Turnstile, ein Limit pro IP in Nitro Storage, Secrets in runtimeConfig und eine CSP über routeRules.
Sende das Formular mit $fetch an eine Server-Route, die Versuche pro Client-IP in Nitro Storage zählt, das Turnstile-Token mit siteverify verifiziert und die Felder mit dem Honeypot weiterleitet. Halte die Secrets in privater runtimeConfig, zur Laufzeit gesetzt über NUXT_-Umgebungsvariablen. Sende die CSP für die Kontaktseite über routeRules und erlaube challenges.cloudflare.com für das Skript und sein Frame.
Was in Nuxt anders ist
Nuxts Server ist Nitro, und Nitro bringt zwei Dinge mit, die eine Spamprüfung braucht. Server-Routen in server/api sind einfache Endpunkte, an die $fetch schon angebunden ist, und useStorage() gibt dir einen Key-Value-Speicher, dessen Backend du pro Umgebung wählst: Arbeitsspeicher in der Entwicklung, Redis oder ein anderer Treiber in der Produktion. Damit wird das Rate-Limit zu einer Konfigurationsänderung statt zu einer Abhängigkeit.
Zwei Nuxt-Gewohnheiten machen hier Ärger. getRequestIP(event, { xForwardedFor: true }) gibt den ersten X-Forwarded-For-Eintrag zurück, den die h3-Doku selbst als Client-Eingabe bezeichnet. Und .env-Dateien werden in der Produktion nicht gelesen, Secrets müssen also als Umgebungsvariablen der Plattform gesetzt werden.
- Die Komponente sendet die Felder und das Turnstile-Token mit $fetch an /api/contact.
- Die Server-Route liest die Client-IP aus dem Header, der in runtimeConfig angegeben ist.
- Sie zählt die Posts der IP in der aktuellen Minute in Nitro Storage. Über fünf wirft sie einen 429 mit den verbleibenden Sekunden.
- Unter dem Limit verifiziert sie das Token mit siteverify und prüft Action und Host.
- Dann leitet sie die Felder an Ikiform weiter, das sie speichert und den Honeypot bewertet.
Projekt-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" },
},
},
},
});- Jeder
runtimeConfig-Schlüssel kann zur Laufzeit durch eineNUXT_-Variable überschrieben werden:turnstileSecretKeydurchNUXT_TURNSTILE_SECRET_KEY,public.turnstileSiteKeydurchNUXT_PUBLIC_TURNSTILE_SITE_KEY. Schlüssel außerhalb vonpublicerreichen nie den Browser. routeRulessendet die CSP nur auf/contact. Nuxt bettet seine Payload- und Hydration-Skripte inline ein, diese Policy behält also'unsafe-inline'; das Community-Modulnuxt-securitykann Nonces erzeugen, wenn du eine strengere Policy brauchst.- Der Treiber
upstashwird nur in der Produktion gemountet und liestUPSTASH_REDIS_REST_URLundUPSTASH_REDIS_REST_TOKENaus der Umgebung. Er braucht das Paket@upstash/redis, installiere es also mitnpm install @upstash/redis.
Server-Utilities
Dateien in server/utils werden automatisch in jede Server-Route importiert:
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;
}
}clientIpliest genau einen Header, den, den dein Host überschreibt. SetzeNUXT_CLIENT_IP_HEADERauf Vercel aufx-forwarded-for, auf Netlify aufx-nf-client-connection-ipund auf Cloudflare aufcf-connecting-ip.- Der Zähler ist ein festes Fenster in Nitro Storage. Lesen und dann Schreiben ist nicht atomar, zwei Posts im selben Moment können also beide unter dem Limit durchrutschen; für ein Kontaktformular ist das akzeptabel. Schlüssel laufen nicht von selbst ab, gib der Redis-Datenbank also eine Eviction-Policy.
$fetchsendet den siteverify-Body als JSON, was siteverify annimmt. Die Host-Prüfung nutztgetRequestHostund wird in der Entwicklung übersprungen, weil Test-Tokens einen Dummy-Hostnamen tragen.
Die 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 };
});- Das Suffix
.post.tslässt Nitro nur aufPOSTantworten. Alles andere bekommt405. - Das Limit läuft vor
readBody, eine Flut kostet also nie ein Body-Parsing oder einen siteverify-Aufruf. - Jeder Fehler wirft
createError. Der JSON-Body enthältmessage, das die Komponente anzeigt. - Der
Origindes Besuchers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenloselocalhost-Tests zu erkennen. - Der Honeypot
_gotchawird unverändert mitgeschickt, Ikiform speichert einen ausgefüllten also als Spam, statt ihn zu verwerfen.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Das Composable und das Formular
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>- Das Composable läuft nur in
onMounted, das Server-Rendering berührtwindowalso nie. - Der Button wartet auf ein Token, und
reset()fordert nach jedem Fehler ein neues an, weil ein Token einmal funktioniert und 300 Sekunden lebt. - Dieses Formular braucht JavaScript. Eine Version, die ohne JavaScript sendet, findest du im nächsten Abschnitt.
- Die Klasse
.hpversteckt den Honeypot außerhalb des sichtbaren Bereichs, wie in der Honeypot-Anleitung.
Lokal testen
In der Entwicklung liegt der Mount ratelimit im Arbeitsspeicher, die Zählung setzt sich also zurück, wenn nuxt dev neu startet. Du kannst das Limit ohne Browser bei der Arbeit beobachten. Posts ohne Token scheitern an siteverify mit 403, und jeder zählt trotzdem:
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"}'
doneDie ersten fünf Zeilen geben 403 aus und die sechste 429. Lokal setzt kein Proxy den IP-Header, also zählt jeder Request als unknown; das ist zu erwarten, und deshalb steht der Header in der Konfiguration statt im Code.
Deployment
| Host | NUXT_CLIENT_IP_HEADER | Storage-Treiber |
|---|---|---|
| Vercel | x-forwarded-for | upstash, aus dem Vercel Marketplace |
| Netlify | x-nf-client-connection-ip | upstash oder redis |
| Cloudflare | cf-connecting-ip | upstash; Workers KV ist eventually consistent, Zählungen hinken also hinterher |
| Node-Server | Der Header, den dein Proxy setzt | redis, oder Arbeitsspeicher bei einer einzelnen Instanz |
Setze jede NUXT_-Variable im Dashboard des Hosts. Nuxt liest sie beim Serverstart, eine Schlüsseländerung braucht also einen Neustart, keinen neuen Build. Trag jeden Produktions- und Preview-Hostnamen im Turnstile-Widget ein.
Ikiform die Prüfungen ausführen lassen
Ohne die Server-Route kann die Kontaktseite mit dem Rest einer nuxt generate-Site vorgerendert werden. Richte ein einfaches Formular mit method="post" und action auf deine Endpunkt-URL aus und lösche server/api/contact.post.ts und das Composable.
Setze Spam check des Formulars auf Ikiform. Ein Post ohne Token bekommt eine kurze Prüfseite auf ikiform.com, die Turnstile ausführt und die Felder weiterschickt, deine CSP braucht also keine Cloudflare-Einträge. Wähle das Rate limit Strict, 2 alle 10 Minuten pro Besucher, und trag deine Domain unter Allowed domains ein. Ikiform bewahrt abgefangenen Spam 30 Tage mit den Gründen auf, aus denen er abgefangen wurde. Die Nuxt-Einrichtungsanleitung behandelt Endpunkt und Schlüssel.
Prüfen, ob es funktioniert
- Führe
nuxt devmit den Testschlüsseln aus und sende eine Nachricht. Sie erscheint unter Submissions. Ein Workspace ohne Tarif nimmt 50localhost-Tests pro Monat an. - Sende sechs Nachrichten innerhalb einer Minute. Die sechste zeigt, wie viele Sekunden noch übrig sind.
- Setze
NUXT_TURNSTILE_SECRET_KEYauf das Secret, das immer scheitert, und starte neu. Jeder Post zeigt den Fehler der Menschlichkeitsprüfung. - Ruf nach
nuxt build/contactmitcurl -Iab. Die Antwort enthält deineContent-Security-Policy. - Sende einen Post mit ausgefülltem Honeypot,
_gotcha=bot. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
| Secrets sind in der Produktion leer | .env wird in der Produktion zur Laufzeit nicht gelesen. Setze NUXT_TURNSTILE_SECRET_KEY und die anderen in der Umgebung des Hosts. |
| Das Limit setzt sich bei jedem Deployment oder jeder Instanz zurück | Der Storage-Mount liegt noch im Arbeitsspeicher. Prüfe, dass $production.nitro.storage einen gemeinsamen Treiber mountet und seine Zugangsdaten gesetzt sind. |
| Ein Skript umgeht das Limit, indem es Header ändert | Du liest getRequestIP mit xForwardedFor: true auf einem Host, der diesen Header nicht überschreibt. Lies den eigenen Header des Hosts über clientIp. |
window is not defined beim Server-Rendering | Turnstile-Code lief außerhalb von onMounted. Halte jeden Zugriff auf window in den Lifecycle-Hooks des Composables. |
Fragen
Rendere das Widget explizit in onMounted, halte sein Token in einer ref und sende es mit den Feldern an eine Server-Route. Die Route sendet es mit deinem Secret Key aus runtimeConfig an siteverify und macht nur bei Erfolg weiter.
Zähle Requests pro Client-IP mit Nitros useStorage, in der Produktion auf Redis oder Upstash gemountet, damit alle Instanzen die Zählung teilen. Wirf mit createError einen 429, wenn die Zählung dein Limit überschreitet.
Nicht mit xForwardedFor true auf einem Host, der das X-Forwarded-For des Clients durchreicht, denn der erste Eintrag ist, was immer der Client gesendet hat. Lies stattdessen den Header, den dein Host überschreibt.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.