Spam im Kontaktformular stoppen

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.

Preet SutharGründer, Ikiform

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.

Ein Kontakt-Post durch die Nuxt-Server-Route und Nitro StorageContactForm.vue$fetchmitdemTokenclientIp(event)HeaderausruntimeConfiguseStoragedieseMinutezählensiteverifyActionundHostIkiformspeichert,bewertetHoneypot429verbleibendeSekundenimFensterPOST /api/contactunter 5MenschdarüberEin Kontakt-Post durch die Nuxt-Server-Route und Nitro StorageContactForm.vue$fetch mit dem TokenclientIp(event)Header ausruntimeConfiguseStoragediese MinutezählensiteverifyAction und HostIkiformspeichert, bewertetHoneypot429verbleibendeSekunden imFensterPOST /api/contactunter 5Menschdarüber
  1. Die Komponente sendet die Felder und das Turnstile-Token mit $fetch an /api/contact.
  2. Die Server-Route liest die Client-IP aus dem Header, der in runtimeConfig angegeben ist.
  3. 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.
  4. Unter dem Limit verifiziert sie das Token mit siteverify und prüft Action und Host.
  5. Dann leitet sie die Felder an Ikiform weiter, das sie speichert und den Honeypot bewertet.
In der Entwicklung liegt der Storage-Mount im Arbeitsspeicher. In der Produktion spricht derselbe Code mit Redis, weil nuxt.config einen anderen Treiber mountet.

Projekt-Setup

Dateien
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" },
      },
    },
  },
});
  • Jeder runtimeConfig-Schlüssel kann zur Laufzeit durch eine NUXT_-Variable überschrieben werden: turnstileSecretKey durch NUXT_TURNSTILE_SECRET_KEY, public.turnstileSiteKey durch NUXT_PUBLIC_TURNSTILE_SITE_KEY. Schlüssel außerhalb von public erreichen nie den Browser.
  • routeRules sendet die CSP nur auf /contact. Nuxt bettet seine Payload- und Hydration-Skripte inline ein, diese Policy behält also 'unsafe-inline'; das Community-Modul nuxt-security kann Nonces erzeugen, wenn du eine strengere Policy brauchst.
  • Der Treiber upstash wird nur in der Produktion gemountet und liest UPSTASH_REDIS_REST_URL und UPSTASH_REDIS_REST_TOKEN aus der Umgebung. Er braucht das Paket @upstash/redis, installiere es also mit npm install @upstash/redis.

Server-Utilities

Dateien in server/utils werden automatisch in jede Server-Route importiert:

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 liest genau einen Header, den, den dein Host überschreibt. Setze NUXT_CLIENT_IP_HEADER auf Vercel auf x-forwarded-for, auf Netlify auf x-nf-client-connection-ip und auf Cloudflare auf cf-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.
  • $fetch sendet den siteverify-Body als JSON, was siteverify annimmt. Die Host-Prüfung nutzt getRequestHost und wird in der Entwicklung übersprungen, weil Test-Tokens einen Dummy-Hostnamen tragen.

Die 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 };
});
  • Das Suffix .post.ts lässt Nitro nur auf POST antworten. Alles andere bekommt 405.
  • 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ält message, das die Komponente anzeigt.
  • Der Origin des Besuchers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose localhost-Tests zu erkennen.
  • Der Honeypot _gotcha wird unverändert mitgeschickt, Ikiform speichert einen ausgefüllten also als Spam, statt ihn zu verwerfen.
Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.

Das Composable und das Formular

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>
  • Das Composable läuft nur in onMounted, das Server-Rendering berührt window also 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 .hp versteckt 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:

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

Die 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

HostNUXT_CLIENT_IP_HEADERStorage-Treiber
Vercelx-forwarded-forupstash, aus dem Vercel Marketplace
Netlifyx-nf-client-connection-ipupstash oder redis
Cloudflarecf-connecting-ipupstash; Workers KV ist eventually consistent, Zählungen hinken also hinterher
Node-ServerDer Header, den dein Proxy setztredis, 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

  1. Führe nuxt dev mit den Testschlüsseln aus und sende eine Nachricht. Sie erscheint unter Submissions. Ein Workspace ohne Tarif nimmt 50 localhost-Tests pro Monat an.
  2. Sende sechs Nachrichten innerhalb einer Minute. Die sechste zeigt, wie viele Sekunden noch übrig sind.
  3. Setze NUXT_TURNSTILE_SECRET_KEY auf das Secret, das immer scheitert, und starte neu. Jeder Post zeigt den Fehler der Menschlichkeitsprüfung.
  4. Ruf nach nuxt build /contact mit curl -I ab. Die Antwort enthält deine Content-Security-Policy.
  5. 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

ProblemWas 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ückDer 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 ändertDu 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-RenderingTurnstile-Code lief außerhalb von onMounted. Halte jeden Zugriff auf window in den Lifecycle-Hooks des Composables.

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.