Spam im Kontaktformular stoppen8 Min. Lesezeit
Spamschutz für TanStack-Start-Formulare mit Turnstile
Schütze ein TanStack-Start-Formular vor Spam: Function Middleware für die Client-IP, ein Token Bucket, Turnstile in einer Serverfunktion verifiziert und eine CSP.
Füge Function Middleware hinzu, die Client-IP und Origin einmal liest und jeder Serverfunktion als Context übergibt. Validiere in der Serverfunktion die Eingabe, nimm ein Token aus einem Bucket pro IP, verifiziere das Turnstile-Token mit siteverify und leite die Felder mit dem Honeypot weiter. Behalte die CSRF-Middleware, die Start installiert, und füge sie selbst wieder hinzu, wenn du src/start.ts anlegst.
Was in TanStack Start anders ist
Eine Serverfunktion ist ein Same-Origin-RPC-Endpunkt. Start schützt sie mit einer CSRF-Middleware vor anderen Sites, die Sec-Fetch-Site, Origin oder Referer prüft, und das verhindert, dass eine fremde Seite über den Browser eines Besuchers sendet. Ein Skript, das beliebige Header senden kann, stoppt sie nicht. Die Spamprüfungen gehören also weiterhin in die Funktion.
Starts eigener Baustein dafür ist Middleware. Function Middleware läuft vor Validator und Handler, kann Request-Header lesen und Werte über context an den Handler übergeben. Schreib die IP-Ermittlung einmal, und jedes Formular der Site bekommt sie.
- Der Browser oder ein Skript sendet an die URL der Serverfunktion.
- Die CSRF-Middleware von Start lehnt Posts ab, bei denen sie nicht nachweisen kann, dass sie von deinem eigenen Origin kommen.
- Die Besucher-Middleware liest Client-IP und Origin-Header und gibt sie als Context weiter.
- Der Validator normalisiert die Eingabe, bevor der Handler sie sieht.
- Der Handler nimmt ein Token aus dem Bucket dieser IP. Ist keins mehr übrig, gibt er ein Später-erneut-versuchen-Ergebnis zurück.
- Er verifiziert das Turnstile-Token und leitet dann die Felder an Ikiform weiter, das sie speichert und den Honeypot bewertet.
Projekt-Setup
src/
routes/
support.tsx
lib/
use-turnstile.ts
server/
bucket.ts
visitor.ts
support.ts
start.ts
.envVITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for- Vite gibt nur Variablen mit dem Präfix
VITE_an den Browser weiter, gelesen überimport.meta.env. Die Secrets haben kein Präfix, existieren also nur auf dem Server alsprocess.env. - Die Testschlüssel bestehen auf jedem Host, auch auf
localhost. Tausche sie pro Umgebung gegen echte aus.
Function Middleware für die Client-IP
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";
export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});Setze CLIENT_IP_HEADER auf den Header, den dein Host überschreibt: x-forwarded-for auf Vercel, x-nf-client-connection-ip auf Netlify, den Netlify-Mitarbeiter empfehlen, obwohl die Dokumentation in Functions nur context.ip beschreibt, und cf-connecting-ip auf Cloudflare. Auf deinem eigenen Node-Server hinter nginx nutze den Header, den nginx setzt, und stell sicher, dass die App nicht an nginx vorbei erreichbar ist. Die Middleware reicht außerdem den Origin des Besuchers weiter, weil das serverseitige fetch an Ikiform keinen sendet und Ikiform ihn liest, um kostenlose localhost-Tests zu erkennen.
Ein Token Bucket pro IP
Ein festes Fenster lässt ein Skript am Ende einer Minute ein volles Kontingent senden und zu Beginn der nächsten noch eins. Ein Token Bucket füllt einen Versuch nach dem anderen auf, wer einen Tippfehler korrigiert, wartet also Sekunden, keine Minute:
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();
export function take(key: string, now = Date.now()) {
const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
if (tokens < 1) {
buckets.set(key, { tokens, at: now });
return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
}
if (buckets.size > 50_000) buckets.clear();
buckets.set(key, { tokens: tokens - 1, at: now });
return { ok: true, retryAfter: 0 };
}Dieser Zähler liegt im Arbeitsspeicher, was auf einem einzelnen dauerhaft laufenden Server exakt ist, etwa node .output/server/index.mjs in einem Container. Auf Vercel oder Netlify hält jede Funktionsinstanz ihre eigene Map, das tatsächliche Limit ist also unbekannt. Verlege den Bucket dort nach Redis, wie die Seite zu Rate-Limits zeigt, und behalte dieselbe Form take(key).
Die Serverfunktion
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";
const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export interface SupportInput {
email: string;
topic: string;
message: string;
_gotcha: string;
token: string;
}
export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };
async function isHuman(token: string, ip: string) {
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 }),
});
const verdict = (await response.json()) as { success: boolean; action?: string };
return verdict.success && verdict.action === "support";
} catch {
return false;
}
}
export const sendSupportRequest = createServerFn({ method: "POST" })
.middleware([visitor])
.validator((input: SupportInput) => ({
email: String(input.email ?? "").trim(),
topic: TOPICS.includes(input.topic) ? input.topic : "other",
message: String(input.message ?? "").trim().slice(0, 5000),
_gotcha: String(input._gotcha ?? ""),
token: String(input.token ?? "").slice(0, 2048),
}))
.handler(async ({ data, context }): Promise<SupportResult> => {
if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };
const limit = take(context.ip);
if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };
const { token: _token, ...fields } = data;
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}`,
...(context.origin ? { Origin: context.origin } : {}),
},
body: JSON.stringify(fields),
}).catch(() => null);
return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
});- Der Validator läuft vor dem Handler und normalisiert nur die Eingabe. Feldfehler kommen als Ergebnis zurück, das das Formular beim richtigen Eingabefeld anzeigen kann.
- Günstige Prüfungen zuerst: E-Mail- und Längenprüfung kosten nichts, der Bucket kostet ein Map-Lookup, siteverify einen Netzwerkaufruf.
actionmusssupportsein. Ein Token, das mit deinem Site Key für ein anderes Widget erzeugt wurde, wird abgelehnt.- Der Honeypot
_gotchawird weitergeleitet, nicht hier geprüft, ein ausgefüllter wird also als Spam gespeichert, wo du ihn prüfen kannst.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Route und das Widget
Das Widget wird explizit aus einem kleinen Hook gerendert. Ein Promise auf Modulebene lädt das Skript einmal, selbst wenn die Route bei Client-Navigation mehrmals gemountet wird, und das Token kommt über Turnstiles callback:
import { useCallback, useEffect, useRef, useState } from "react";
interface Api {
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<Api> | undefined;
function load() {
loading ??= new Promise<Api>((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?: Api }).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(sitekey: string, action: string) {
const ref = useRef<HTMLDivElement>(null);
const api = useRef<Api | null>(null);
const widget = useRef<string | null>(null);
const [token, setToken] = useState("");
useEffect(() => {
let live = true;
load().then((turnstile) => {
if (!live || !ref.current) return;
api.current = turnstile;
widget.current = turnstile.render(ref.current, {
sitekey,
action,
callback: setToken,
"expired-callback": () => setToken(""),
});
});
return () => {
live = false;
if (widget.current) api.current?.remove(widget.current);
widget.current = null;
};
}, [sitekey, action]);
const reset = useCallback(() => {
setToken("");
if (widget.current) api.current?.reset(widget.current);
}, []);
return { ref, token, reset };
}import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";
export const Route = createFileRoute("/support")({ component: Support });
function Support() {
const send = useServerFn(sendSupportRequest);
const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
const [result, setResult] = useState<SupportResult | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setPending(true);
try {
setResult(
await send({
data: {
email: String(form.get("email") ?? ""),
topic: String(form.get("topic") ?? ""),
message: String(form.get("message") ?? ""),
_gotcha: String(form.get("_gotcha") ?? ""),
token: turnstile.token,
},
}),
);
} catch {
setResult({ ok: false, message: "Couldn't reach the server. Try again." });
} finally {
setPending(false);
turnstile.reset();
}
}
if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
const error = result && !result.ok ? result : null;
return (
<form onSubmit={onSubmit} noValidate>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="other">
<option value="billing">Billing</option>
<option value="bug">Something is broken</option>
<option value="account">My account</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">What happened?</label>
<textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
<div ref={turnstile.ref} />
{error ? <p role="alert">{error.message}</p> : null}
<button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
</form>
);
}- Der Button bleibt deaktiviert, bis Turnstile ein Token übergibt, damit niemand ein leeres absendet.
turnstile.reset()läuft nach jedem Versuch. Ein Token funktioniert einmal und lebt 300 Sekunden, ein neuer Versuch braucht also ein frisches.- Dieses Formular braucht JavaScript. Serverfunktionen können über ihre
.urleinfache Formular-Posts annehmen, aber das Turnstile-Widget braucht ohnehin einen Browser.
Sicherheits-Header in src/start.ts
Start hat keine eigene CSP-Option. Globale Request-Middleware in src/start.ts kann bei jeder Antwort eine setzen. Das Anlegen dieser Datei schaltet die CSRF-Middleware ab, die Start standardmäßig installiert, also füge sie zuerst wieder hinzu:
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";
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'",
"frame-ancestors 'none'",
].join("; ");
const securityHeaders = createMiddleware().server(async ({ next }) => {
setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
return next();
});
export const startInstance = createStart(() => ({
requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));- Das serverseitig gerenderte HTML von Start enthält Inline-Skripte. Ohne Nonce-Setup braucht
script-src'unsafe-inline'; es blockiert trotzdem Skripte von jedem Host, den du nicht aufgeführt hast. - Turnstile zeichnet sein Widget in einem iframe von
challenges.cloudflare.com, dasframe-srcerlauben muss. - Der CSRF-
filterbeschränkt die Prüfung auf Serverfunktionen, normale Seitenaufrufe sind also nicht betroffen.
Deployment
| Host | CLIENT_IP_HEADER | Limiter |
|---|---|---|
| Dein eigener Node-Server oder Container | Was dein Proxy setzt | Im Arbeitsspeicher funktioniert |
| Vercel | x-forwarded-for | Redis |
| Netlify | x-nf-client-connection-ip | Redis |
| Cloudflare Workers | cf-connecting-ip | Rate-Limiting-Binding von Workers |
Vercel dokumentiert, dass es x-forwarded-for mit der Adresse des Besuchers überschreibt. Trag vor dem Deployment jeden Produktions- und Preview-Hostnamen im Turnstile-Widget ein.
Ikiform die Prüfungen ausführen lassen
Wenn du Turnstile-Schlüssel und einen Limiter nicht selbst betreiben willst, lass die Serverfunktion weg und sende aus dem Browser. Ersetze in onSubmit den Aufruf send durch eine Challenge und ein Submit, wobei box eine Ref auf ein leeres div ist, das das Widget ersetzt:
import { challenge, submit } from "@ikiform/sdk";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });Setze Spam check des Formulars auf Ikiform, wähle das Rate limit Strict mit 2 alle 10 Minuten pro Besucher und füge deine Domain unter Allowed domains hinzu. Ikiform führt dann Honeypot, Turnstile auf ikiform.com, das Limit pro IP und die Inhaltsbewertung aus und bewahrt, was es abfängt, 30 Tage auf. Deine CSP braucht dann https://ikiform.com in frame-src und connect-src statt Cloudflare. Die Anleitung zur Server-Einrichtung behandelt Schlüssel, falls du die Serverfunktion behältst.
Prüfen, ob es funktioniert
- Starte den Dev-Server mit den Testschlüsseln und sende eine Anfrage. Sie erscheint unter Submissions. Ohne Tarif sind 50
localhost-Tests pro Monat kostenlos. - Sende schnell vier Anfragen. Die vierte gibt die Später-erneut-versuchen-Meldung mit einer Sekundenzahl zurück.
- Setze
TURNSTILE_SECRET_KEYauf das Secret, das immer scheitert. Jede Anfrage zeigt den Fehler der Menschlichkeitsprüfung. - Wiederhole den Aufruf der Serverfunktion aus dem Netzwerk-Tab mit
curlund ohneOrigin. Die CSRF-Middleware lehnt ihn ab. - 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 |
|---|---|
Cross-Site-Posts erreichen Serverfunktionen, nachdem src/start.ts hinzugefügt wurde | Eine eigene src/start.ts ersetzt die Standard-CSRF-Middleware. Füge createCsrfMiddleware() zu requestMiddleware hinzu, wie gezeigt. |
| Alle Besucher teilen sich einen Bucket | CLIENT_IP_HEADER nennt einen Header, den dein Host nicht setzt, also ist jede IP unknown. Nutze den Header aus der Tabelle oben. |
| Das Limit scheint sich auf Vercel zufällig zurückzusetzen | Jede Funktionsinstanz hat ihren eigenen Speicher. Verlege den Bucket nach Redis. |
| Das Widget wird in der Entwicklung doppelt gerendert | Reacts Strict Mode mountet Effects zweimal. Das Cleanup entfernt das erste Widget, in der Produktion erscheint also eins. |
Fragen
Lies den Header, den dein Host setzt, mit getRequestHeader aus @tanstack/react-start/server, am besten einmal in Function Middleware, die ihn als Context an den Handler übergibt. Nutze x-forwarded-for auf Vercel, x-nf-client-connection-ip auf Netlify und cf-connecting-ip auf Cloudflare.
Ja. Start installiert eine CSRF-Middleware für Serverfunktionen, außer du definierst src/start.ts, dann fügst du createCsrfMiddleware selbst hinzu. Sie blockiert Cross-Site-Posts aus Browsern, keine Skripte, Spamprüfungen sind also weiterhin nötig.
Auf einem einzelnen dauerhaft laufenden Node-Server ja. Auf Serverless-Hosts hat jede Instanz ihren eigenen Speicher, nutze also einen gemeinsamen Speicher wie Redis oder das Rate-Limiting-Binding von Cloudflare.
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.