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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/spam-protection/nuxt

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.

Diagramm: Ein Kontakt-Post durch die Nuxt-Server-Route und Nitro Storage

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

```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" },
      },
    },
  },
});
```

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

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

```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](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `IKIFORM_SECRET_KEY` in reinen Server-Umgebungsvariablen.

## Das Composable und das Formular

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

- 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](https://ikiform.com/de/guides/spam-protection/honeypot.md).

## 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`:

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

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

| 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](https://ikiform.com/de/docs/guides/nuxt.md) 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

| 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

### Wie füge ich Turnstile zu einem Nuxt-Formular hinzu?

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.

### Wie begrenze ich eine Nuxt-Server-Route?

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.

### Ist getRequestIP in Nuxt sicher fürs Rate-Limiting?

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.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Spam im Kontaktformular stoppen](https://ikiform.com/de/guides/spam-protection.md): Stopp Kontaktformular-Spam mit Schichten, die echte Menschen nichts kosten: Honeypot, Timing, Inhaltsbewertung, Rate-Limits und eine Menschlichkeitsprüfung nur bei Bedarf.
- [Cloudflare Turnstile](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md): Füge Cloudflare Turnstile zu einem einfachen HTML-Formular hinzu: das Widget, serverseitiges siteverify, Testschlüssel und ein Weg ganz ohne eigene Schlüssel oder Server.
- [Rate-Limits](https://ikiform.com/de/guides/spam-protection/rate-limiting.md): Begrenze Formulareinsendungen pro Besucher mit einem gemeinsamen Speicher, antworte mit 429 und Retry-After und sperre keine Büros aus, die sich eine IP teilen.
- [Honeypot-Feld](https://ikiform.com/de/guides/spam-protection/honeypot.md): Füge jedem Formular einen Honeypot hinzu, der Bots abfängt, ohne Screenreader, Tastatur oder Autofill zu stören, mit der Serverprüfung für jeden Stack.
- [Ohne Captcha](https://ikiform.com/de/guides/spam-protection/without-captcha.md): Stopp den meisten Formular-Spam ganz ohne Captcha: Honeypot, signierte Render-Zeit, Inhaltsbewertung und Rate-Limit, zusammen statt einzeln eingesetzt.
- [Alternativen zu reCAPTCHA](https://ikiform.com/de/guides/spam-protection/recaptcha-alternatives.md): Vergleiche reCAPTCHA-Alternativen für Formulare, von Turnstile und hCaptcha bis Proof-of-Work und ganz ohne Captcha, und wechsle mit einer kleinen, sicheren Änderung.
