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.
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.
- Das Formular oder ein Skript, das es nachahmt, sendet an die Server Action.
- 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.
- Sie sendet das Turnstile-Token und die Client-IP an siteverify und prüft Action-Name und Hostname in der Antwort.
- Erst dann leitet sie die Felder, Honeypot eingeschlossen, an Ikiform weiter, das die Nachricht bewertet und speichert.
Projekt-Setup
Die Dateien, die diese Seite einem App-Router-Projekt hinzufügt:
app/
contact/
page.tsx
contact-form.tsx
actions.ts
lib/
client-ip.ts
turnstile.ts
proxy.ts
.env.localLeg 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:
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_KEYerreicht den Browser. Next.js setztNEXT_PUBLIC_-Werte zur Build-Zeit ein, eine Änderung des Site Keys braucht also einen neuen Build. TURNSTILE_HOSTNAMEbleibt 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:
- clientIp() liest den einen Header, der in CLIENT_IP_HEADER steht, und ignoriert den Rest.
- Vercel überschreibt x-forwarded-for und x-real-ip mit der Adresse des Besuchers.
- Netlify legt die Adresse in x-nf-client-connection-ip ab.
- Cloudflare, auch für Next.js, das mit OpenNext deployt wird, setzt cf-connecting-ip.
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
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-duplicatezurück. successallein reicht nicht. Ein Token, das mit deinem Site Key auf einer anderen Site erzeugt wurde, sagt ebenfallssuccess, daher prüft der Helferactionund in der Produktionhostname.- Er schlägt sicher fehl. Ist Cloudflare nicht erreichbar, sieht der Besucher einen Fehler, und kein Bot kommt durch.
Die Server Action
"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 kostenloselocalhost-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.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Das Formular und das Widget
"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=explicitverhindert, dass das Skript die Seite nach Widgets durchsucht.onReadylä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
actiondie Server Action selbst ist. Ohne JavaScript gibt es kein Widget, also lehnt siteverify den Post ab; die Menschlichkeitsprüfung braucht einen Browser. - Die Klasse
.hpaus der Honeypot-Anleitung versteckt den Honeypot außerhalb des sichtbaren Bereichs.
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:
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 inheaders()vonnext.config, zum Preis von'unsafe-inline'.
Deployment
| Host | CLIENT_IP_HEADER | Rate-Limit | Hinweise |
|---|---|---|---|
| Vercel | x-real-ip | Firewall-Regel und checkRateLimit | Füge pro Umgebung, in der du testest, eine Regel hinzu |
| Netlify | x-nf-client-connection-ip | Redis, etwa Upstash | Server Actions laufen in einer Netlify-Funktion |
| Cloudflare, mit OpenNext | cf-connecting-ip | Rate-Limiting-Binding von Workers | Das 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:
"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
- Starte
next devmit den Testschlüsseln und sende eine Nachricht. Sie kommt unter Submissions an. Ohne Tarif nimmt ein Workspace 50 Testeinsendungen pro Monat vonlocalhostan. - Setze
TURNSTILE_SECRET_KEYauf das Secret, das immer scheitert, und sende ab. Das Formular zeigt den Fehler der Menschlichkeitsprüfung und behält deinen Text. - Sende auf einer Vercel-Preview mit der Firewall-Regel sechsmal in einer Minute ab. Beim sechsten Mal erscheint die Limit-Meldung.
- 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. - Öffne die Seite mit sichtbarer Konsole. Es gibt keine CSP-Verstöße für
challenges.cloudflare.com.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
| Das Widget erscheint nach der Navigation zur Seite nie | Das 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üfung | TURNSTILE_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-Bucket | CLIENT_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ück | In dieser Umgebung hat keine Firewall-Regel die Rate limit ID contact-form. Füge sie hinzu und veröffentliche dann die Firewall-Änderungen. |
Fragen
Rendere das Widget in einer Client-Komponente, lade das Skript mit next/script und lies das Feld cf-turnstile-response in der Server Action. Sende es mit deinem Secret Key an siteverify und mach nur weiter, wenn die Antwort success mit der erwarteten Action und dem erwarteten Hostnamen lautet.
Lies sie mit headers() aus next/headers aus dem Header, den dein Host setzt: x-real-ip oder x-forwarded-for auf Vercel, x-nf-client-connection-ip auf Netlify, cf-connecting-ip auf Cloudflare. Next.js hat im App Router kein request.ip.
Ja. Eine Nonce wird pro Request erzeugt, die Seite muss also bei jedem Request gerendert werden. Halte die Kontaktseite dynamisch oder nutze eine Policy ohne Nonces, die challenges.cloudflare.com per Host erlaubt.
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.