Spam im Kontaktformular stoppen

Spam im Kontaktformular stoppen8 Min. Lesezeit

Spamschutz für Next.js-Formulare mit Turnstile

Schütze ein Next.js-Formular vor Spam: Honeypot, Turnstile in einer Server Action geprüft, ein Limit pro IP auf Vercel, Netlify oder Cloudflare und eine passende CSP.

Preet SutharGründer, Ikiform

Prüfe in Next.js auf Spam in der Server Action, weil die Action ein öffentlicher POST-Endpunkt ist, den jeder ohne dein Formular aufrufen kann. Begrenze nach der Client-IP, die dein Host setzt, verifiziere das Turnstile-Token mit siteverify und leite dann die Felder mit intaktem Honeypot weiter. Lade das Turnstile-Skript über next/script mit deiner CSP-Nonce und erlaube challenges.cloudflare.com in frame-src.

Was in Next.js anders ist

Eine Server Action sieht aus wie ein Funktionsaufruf, aber Next.js macht daraus einen POST-Endpunkt mit einer ID im JavaScript der Seite. Ein Bot braucht dein Formular nicht. Er kann diesen POST mit beliebigen Feldern wiederholen, so schnell er will. Jede Prüfung gehört also in die Action, nie nur in die Komponente.

Drei Details von Next.js prägen den Rest dieser Seite:

  • Es gibt kein request.ip. Die Client-IP kommt aus einem Header, und welchem du trauen kannst, hängt vom Host ab.
  • Eine Nonce bedeutet dynamisches Rendern. Eine Nonce pro Request kann nicht für eine Seite gelten, die zur Build-Zeit gebaut wurde.
  • Client-Navigation hält die Seite am Leben. Ein einmal geladenes Turnstile-Skript rendert nach einer Soft-Navigation kein Widget mehr.
Die Reihenfolge der Prüfungen in der Next.js-Server-ActionServerActioneinPOST,denjedersendenkannRate-LimitVercel-Firewall-Regel,pro IPsiteverifyToken,Action,HostnameIkiformbewertetdenHoneypot,speichertFehlerzustandnichtsweitergeleitetunter dem LimitMenschüber dem LimitDie Reihenfolge der Prüfungen in der Next.js-Server-ActionServer Actionein POST, den jedersenden kannRate-LimitVercel-Firewall-Regel,pro IPsiteverifyToken, Action,HostnameIkiformbewertet den Honeypot,speichertFehlerzustandnichtsweitergeleitetunter dem LimitMenschüber dem Limit
  1. Das Formular oder ein Skript, das es nachahmt, sendet an die Server Action.
  2. Die Action prüft den Zähler des Besuchers gegen eine Vercel-Firewall-Regel. Über dem Limit gibt sie einen Fehlerzustand zurück und hört auf.
  3. Sie sendet das Turnstile-Token und die Client-IP an siteverify und prüft Action-Name und Hostname in der Antwort.
  4. Erst dann leitet sie die Felder, Honeypot eingeschlossen, an Ikiform weiter, das die Nachricht bewertet und speichert.
Die günstigste Prüfung läuft zuerst. Der Honeypot wird unverändert weitergereicht, ein ausgefüllter wird also als Spam gespeichert statt still verworfen.

Projekt-Setup

Die Dateien, die diese Seite einem App-Router-Projekt hinzufügt:

Dateien
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local

Leg im Cloudflare-Dashboard ein Turnstile-Widget an und trag jeden Hostnamen ein, auf dem das Formular läuft. Nutze beim Bauen die Testschlüssel von Cloudflare, die auf jedem Host bestehen, auch auf localhost:

.env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip
  • Nur NEXT_PUBLIC_TURNSTILE_SITE_KEY erreicht den Browser. Next.js setzt NEXT_PUBLIC_-Werte zur Build-Zeit ein, eine Änderung des Site Keys braucht also einen neuen Build.
  • TURNSTILE_HOSTNAME bleibt lokal leer, weil Test-Tokens deinen Hostnamen nicht enthalten. Setze es in der Produktion auf deine echte Domain.
  • Tausche das Secret gegen 2x0000000000000000000000000000000AA, um jede Prüfung scheitern zu sehen.

Die Client-IP lesen, die dein Host setzt

Ein Rate-Limit, das auf einem Header beruht, den der Besucher schreiben kann, ist kein Limit. Jeder Host setzt einen Header, den er beim Eingang überschreibt, und nur diesem kannst du trauen:

Welcher Header bei welchem Host die echte Client-IP enthälteingehender RequestclientIp()liestCLIENT_IP_HEADERVercelx-real-ip oderx-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ipWelcher Header bei welchem Host die echte Client-IP enthälteingehenderRequestclientIp()liestCLIENT_IP_HEADERVercelx-real-ip oderx-forwarded-forNetlifyx-nf-client-connection-ipCloudflarecf-connecting-ip
  1. clientIp() liest den einen Header, der in CLIENT_IP_HEADER steht, und ignoriert den Rest.
  2. Vercel überschreibt x-forwarded-for und x-real-ip mit der Adresse des Besuchers.
  3. Netlify legt die Adresse in x-nf-client-connection-ip ab.
  4. Cloudflare, auch für Next.js, das mit OpenNext deployt wird, setzt cf-connecting-ip.
Vercel dokumentiert, dass es X-Forwarded-For überschreibt, um Spoofing zu verhindern. Netlifys Dokumentation nennt in Functions nur context.ip; x-nf-client-connection-ip ist der Header, auf den Netlify-Mitarbeiter verweisen.
lib/client-ip.ts
import "server-only";
import { headers } from "next/headers";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";

export async function clientIp() {
  const value = (await headers()).get(HEADER);
  return value?.split(",")[0]?.trim() || null;
}

Turnstile in einem reinen Server-Helfer verifizieren

lib/turnstile.ts
import "server-only";

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

export type Check = { ok: true } | { ok: false; reason: string };

export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
  if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };

  let verdict: Verdict;
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip ?? undefined }),
      signal: AbortSignal.timeout(5000),
    });
    verdict = (await response.json()) as Verdict;
  } catch {
    return { ok: false, reason: "siteverify-unreachable" };
  }

  if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
  if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
  const expected = process.env.TURNSTILE_HOSTNAME;
  if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
  return { ok: true };
}
  • Ein Token ist 300 Sekunden gültig und kann einmal verifiziert werden, danach gibt siteverify timeout-or-duplicate zurück.
  • success allein reicht nicht. Ein Token, das mit deinem Site Key auf einer anderen Site erzeugt wurde, sagt ebenfalls success, daher prüft der Helfer action und in der Produktion hostname.
  • Er schlägt sicher fehl. Ist Cloudflare nicht erreichbar, sieht der Besucher einen Fehler, und kein Bot kommt durch.

Die Server Action

app/contact/actions.ts
"use server";

import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";

export interface ContactState {
  status: "idle" | "sent" | "error";
  message: string;
  fields?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const fields = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  };

  if (process.env.VERCEL) {
    const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
    if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
  }

  const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
  if (!check.ok) {
    console.warn("turnstile", check.reason);
    return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
  }

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      Origin: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
  return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}

Auf Vercel kommt das Limit von der Firewall. Öffne die Firewall deines Projekts, füge eine Regel mit der Bedingung @vercel/firewall hinzu, setze die Rate limit ID auf contact-form und wähle ein Fenster wie 5 Requests pro Minute. checkRateLimit nimmt die Request-Header, funktioniert also in einer Server Action, in der es kein Request-Objekt gibt, und gruppiert nach Client-IP, außer du übergibst einen rateLimitKey.

  • Vercel zählt jede Regel pro Region, Traffic über mehrere Regionen kann die eingestellte Zahl also überschreiten. Für ein Kontaktformular ist das in Ordnung; für eine exakte globale Zählung nutze Redis, wie auf der Seite zu Rate-Limits.
  • Der Origin-Header des Besuchers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose localhost-Tests zu erkennen.
  • Außerhalb von Vercel wird der Firewall-Aufruf übersprungen. Setze dort einen Redis- oder Workers-Limiter ein, mit clientIp() als Schlüssel.
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 Formular und das Widget

app/contact/contact-form.tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";

interface Turnstile {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: Turnstile;
  }
}

const initial: ContactState = { status: "idle", message: "" };

export function ContactForm({ nonce }: { nonce?: string }) {
  const [state, action, pending] = useActionState(sendMessage, initial);
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mount() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      action: "contact",
    });
  }

  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  useEffect(
    () => () => {
      if (widget.current) window.turnstile?.remove(widget.current);
      widget.current = null;
    },
    [],
  );

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.fields?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.message} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
    </form>
  );
}
  • ?render=explicit verhindert, dass das Skript die Seite nach Widgets durchsucht. onReady läuft, sobald das Skript geladen ist, und erneut bei jedem Mounten der Komponente, was clientseitige Navigation abdeckt.
  • Ein Token funktioniert einmal. Wenn die Action einen Fehler zurückgibt, setzt der Effect das Widget zurück, damit der nächste Versuch ein frisches Token mitbringt.
  • Das Formular sendet auch vor der Hydration, weil action die Server Action selbst ist. Ohne JavaScript gibt es kein Widget, also lehnt siteverify den Post ab; die Menschlichkeitsprüfung braucht einen Browser.
  • Die Klasse .hp aus der Honeypot-Anleitung versteckt den Honeypot außerhalb des sichtbaren Bereichs.
app/contact/page.tsx
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm nonce={nonce} />
    </main>
  );
}

Eine CSP, die Turnstile laufen lässt

Next.js liest die Nonce aus dem Request-Header Content-Security-Policy und fügt sie seinen eigenen Skripten und jedem <Script> mit der Prop nonce hinzu. Laut Cloudflare reicht Turnstile die Nonce an das weiter, was es lädt, und sein iframe braucht frame-src https://challenges.cloudflare.com. In Next.js 16 heißt die Datei proxy.ts, die middleware.ts ersetzt hat:

proxy.ts
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const dev = process.env.NODE_ENV === "development";
  const policy = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
    "frame-src https://challenges.cloudflare.com",
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", policy);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
  • Mit 'strict-dynamic' vertrauen moderne Browser der Nonce und ignorieren den Host, der für ältere Browser bleibt.
  • Wenn die Seite headers() liest, wird sie pro Request gerendert, was eine Nonce voraussetzt. Eine statisch gebaute Kontaktseite kann keine tragen; nutze stattdessen eine Policy ohne Nonces in headers() von next.config, zum Preis von 'unsafe-inline'.

Deployment

HostCLIENT_IP_HEADERRate-LimitHinweise
Vercelx-real-ipFirewall-Regel und checkRateLimitFüge pro Umgebung, in der du testest, eine Regel hinzu
Netlifyx-nf-client-connection-ipRedis, etwa UpstashServer Actions laufen in einer Netlify-Funktion
Cloudflare, mit OpenNextcf-connecting-ipRate-Limiting-Binding von WorkersDas Binding ist ungefähr und wird pro Standort gezählt

Setze TURNSTILE_HOSTNAME in der Produktion auf deine echte Domain. Preview-Deployments bekommen eigene Hostnamen, also füge sie dem Widget hinzu oder lass die Prüfung in Previews aus.

Ikiform die Prüfungen ausführen lassen

All das oben gibt es nur, weil die Server Action mit einem Secret Key weiterleitet, was die eigene Menschlichkeitsprüfung und das Limit pro Besucher von Ikiform abschaltet. Sende stattdessen aus dem Browser, dann führt Ikiform Honeypot, Turnstile und Rate-Limit für dich aus. Es gibt keine Cloudflare-Schlüssel, keine Änderungen an proxy.ts außer dem Erlauben von https://ikiform.com in frame-src und connect-src und keine Action:

app/contact/contact-form.tsx
"use client";

import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const frame = useRef<HTMLDivElement>(null);
  const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
    try {
      const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      return result.ok ? "sent" : result.error.message;
    } catch {
      return "The human check didn't finish. Try again.";
    }
  }, "");

  if (message === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={frame} />
      {message ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}

Setze Spam check des Formulars auf Ikiform und sein Rate limit auf Strict, 2 alle 10 Minuten pro Besucher, und füge deine Domain plus localhost unter Allowed domains hinzu. challenge() bindet einen kleinen ikiform.com-Frame ein, der unsichtbar bleibt, außer Turnstile braucht einen Klick. Spam bekommt dieselbe Erfolgsantwort wie eine echte Nachricht, wird mit den Gründen gespeichert, aus denen er abgefangen wurde, und 30 Tage aufbewahrt, bevor er gelöscht wird. Die Next.js-Einrichtungsanleitung behandelt das Anlegen des Formulars und seiner Schlüssel.

Prüfen, ob es funktioniert

  1. Starte next dev mit den Testschlüsseln und sende eine Nachricht. Sie kommt unter Submissions an. Ohne Tarif nimmt ein Workspace 50 Testeinsendungen pro Monat von localhost an.
  2. Setze TURNSTILE_SECRET_KEY auf das Secret, das immer scheitert, und sende ab. Das Formular zeigt den Fehler der Menschlichkeitsprüfung und behält deinen Text.
  3. Sende auf einer Vercel-Preview mit der Firewall-Regel sechsmal in einer Minute ab. Beim sechsten Mal erscheint die Limit-Meldung.
  4. 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.
  5. Öffne die Seite mit sichtbarer Konsole. Es gibt keine CSP-Verstöße für challenges.cloudflare.com.

Stolperfallen

ProblemWas zu tun ist
Das Widget erscheint nach der Navigation zur Seite nieDas Skript wurde auf einer früheren Seite geladen und durchsucht die Seite nicht erneut. Rendere aus onReady heraus, wie im Beispiel, nicht aus onLoad.
Jede Nachricht scheitert in der Produktion an der MenschlichkeitsprüfungTURNSTILE_HOSTNAME passt nicht zum Hostnamen, den siteverify zurückgibt, oft www. gegenüber der Apex-Domain. Logge check.reason, um zu sehen, welcher es ist.
Alle Besucher teilen sich einen Rate-Limit-BucketCLIENT_IP_HEADER nennt einen Header, den dein Host nicht setzt, also gibt clientIp() null zurück. Wähle den Header aus der Tabelle oben.
checkRateLimit gibt error: "not-found" zurückIn dieser Umgebung hat keine Firewall-Regel die Rate limit ID contact-form. Füge sie hinzu und veröffentliche dann die Firewall-Änderungen.

Fragen

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