Spam im Kontaktformular stoppen7 Min. Lesezeit
Spamschutz für React-Router-Formulare mit Turnstile
Schütze ein React-Router-Formular vor Spam: eine Route-Action mit Turnstile, ein Workers-Rate-Limit nach cf-connecting-ip, ein .server-Modul und ein CSP-Header.
Führe die Prüfungen in der action der Route aus: Begrenze nach der Client-IP, verifiziere das Turnstile-Token mit siteverify und leite dann die Felder mit dem Honeypot weiter. Halte die Helfer in einem .server.ts-Modul, damit sie nie den Browser erreichen, und setze die CSP aus dem headers-Export der Route. Auf Cloudflare Workers kommen das Rate-Limiting-Binding und die Secrets aus env in cloudflare:workers.
Was in React Router anders ist
Im Framework-Modus nimmt jede Route mit einer action POSTs an, von deinem Formular oder von jedem anderen. React Router gibt dir drei Werkzeuge, die bestimmen, wie Spamprüfungen hineinpassen:
.server-Module. Eine Datei namensspam.server.tskann nicht in Client-Code gebündelt werden, Secrets und siteverify-Aufrufe bleiben also auf dem Server, selbst wenn eine Komponente aus derselben Route importiert.- Der
headers-Export. Header, die eine Action zurückgibt, werden bei einer Dokument-Antwort nicht gesendet, außer dieheaders-Funktion der Route reicht sie weiter. Auch die CSP liegt dort. useFetcher. Es sendet an die Action ohne Navigation, Seite und Widget bleiben also zwischen den Versuchen gemountet. Deshalb wird das Widget nach jeder Antwort mit einemkeyneu gemountet.
Diese Seite deployt auf Cloudflare Workers, ein verbreitetes Zuhause für React Router, wo ein Rate-Limiting-Binding eingebaut ist. Die Deployment-Tabelle behandelt Vercel und Netlify.
- Nach der Hydration sendet das Formular über useFetcher, die Seite navigiert also nicht.
- Die Action begrenzt nach IP, verifiziert das Turnstile-Token und leitet die Felder weiter.
- Ihr Ergebnis kommt als fetcher.data an.
- Der key des Widgets ändert sich, sodass ein frisches Widget und ein frisches Token für den nächsten Versuch bereitstehen.
- Vor der Hydration sendet dasselbe Formular als normaler Dokument-Request, ohne Turnstile-Token.
- Die Action lehnt es bei siteverify ab.
- Der headers-Export reicht Retry-After und die CSP an die Dokument-Antwort weiter.
- Die Komponente liest actionData statt fetcher.data und zeigt den Fehler.
Projekt-Setup
Starte mit dem React-Router-Template von Cloudflare mit @cloudflare/vite-plugin. In React Router 8 ist das Argument context immer ein RouterContextProvider, und ein einfaches Objekt { cloudflare: { env } } aus dem Worker-Einstiegspunkt wirft einen Fehler, daher übergibt der Einstiegspunkt nur den Request, und die Route liest Bindings mit import { env } from "cloudflare:workers". Die Dateien, die diese Seite anfasst:
app/
routes/
contact.tsx
components/
turnstile.tsx
lib/
spam.server.ts
workers/
app.ts
wrangler.jsonc
.dev.varsimport { createRequestHandler } from "react-router";
const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);
export default {
fetch(request) {
return handler(request);
},
} satisfies ExportedHandler<Env>;{
"name": "acme-site",
"main": "./workers/app.ts",
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
"ratelimits": [
{
"name": "CONTACT_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key- Der Site Key ist öffentlich, er steht also in
vars. Der Loader reicht ihn an die Komponente weiter, der Client-Build braucht also überhaupt keine Umgebungsvariable. - Secrets gehören lokal in
.dev.varsund in der Produktion inwrangler secret put, nie inwrangler.jsonc. - Führe nach dem Bearbeiten der Konfiguration
wrangler typesaus, damitCONTACT_LIMITund die Secrets aufenvtypisiert sind.
Die reinen Server-Helfer
export async function isHuman(secret: string, token: string, ip: string, host: string) {
if (!token || token.length > 2048) return false;
const body = new URLSearchParams({ secret, response: token, remoteip: ip });
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
} catch {
return false;
}
}
export async function forward(secretKey: string, form: FormData, origin: string | null) {
const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
if (origin) headers.set("Origin", origin);
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
return response?.ok ?? false;
}- Das Suffix
.serverlässt den Build scheitern, wenn Client-Code dieses Modul einbinden würde. - Siteverify nimmt form-encoded Bodys an. Die Hostnamen-Prüfung vergleicht mit dem Host, an den der Besucher gesendet hat, und wird in der Entwicklung übersprungen, weil Test-Tokens einen Dummy-Hostnamen tragen.
forwardsendet die ursprünglichenFormData, jedes Feld, einschließlich des Honeypots, erreicht Ikiform also so, wie es gesendet wurde.- Es reicht außerdem den
Origindes Besuchers weiter, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenloselocalhost-Tests zu erkennen.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Das Widget
import { useEffect, useRef } from "react";
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
interface TurnstileApi {
render(container: HTMLElement, options: { sitekey: string; action: string }): string;
remove(widgetId: string): void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
export function Turnstile({ siteKey }: { siteKey: string }) {
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
let widgetId: string | undefined;
const mount = () => {
if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
};
if (window.turnstile) mount();
else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
return () => {
if (widgetId) window.turnstile?.remove(widgetId);
};
}, [siteKey]);
return (
<>
<script src={SCRIPT} async />
<div ref={box} />
</>
);
}React 19 hebt ein async-<script> in den Head und lädt es einmal, egal wie oft die Komponente rendert. Der Effect rendert das Widget explizit, was Cloudflare für Single-Page-Apps empfiehlt, und entfernt es beim Unmount.
Die Route
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";
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 function loader() {
return { siteKey: env.TURNSTILE_SITE_KEY };
}
export async function action({ request }: Route.ActionArgs) {
const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) {
return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
}
const form = await request.formData();
const token = String(form.get("cf-turnstile-response") ?? "");
if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
}
form.delete("cf-turnstile-response");
const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}
export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
parentHeaders.set("Content-Security-Policy", POLICY);
const retry = actionHeaders.get("Retry-After");
if (retry) parentHeaders.set("Retry-After", retry);
return parentHeaders;
}
export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
const fetcher = useFetcher<typeof action>();
const result = fetcher.data ?? actionData;
const [round, setRound] = useState(0);
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
}, [fetcher.state, fetcher.data]);
if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;
return (
<fetcher.Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<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} />
<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>
<Turnstile key={round} siteKey={loaderData.siteKey} />
{result?.error ? <p role="alert">{result.error}</p> : null}
<button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
</fetcher.Form>
);
}- Das Limit läuft vor
request.formData(), eine Flut kostet also nie ein Body-Parsing oder einen siteverify-Aufruf. cf-connecting-ipsetzt Cloudflare bei jedem Request an einen Worker, und der Besucher kann es nicht setzen.data()setzt Status undRetry-After. Ein Fetcher liest den Body direkt; derheaders-Export bringt diese Header in eine Dokument-Antwort.resultfällt aufactionDatazurück, damit der Fehler auch angezeigt wird, wenn das Formular vor der Hydration gesendet hat.- Die Klasse
.hpversteckt den Honeypot außerhalb des sichtbaren Bereichs, wie in der Honeypot-Anleitung.
React Router rendert für die Hydration Inline-Skripte, daher behält diese Policy 'unsafe-inline' in script-src. Um darauf zu verzichten, erzeuge in entry.server.tsx eine Nonce und übergib sie an <Scripts> und das Turnstile-Skript; laut Cloudflare reicht Turnstile eine Nonce an das weiter, was es lädt.
Lokal testen
Laut Cloudflares Dokumentation führt sein Vite-Plugin deinen Servercode während der Entwicklung in der Workers-Runtime aus, env aus cloudflare:workers, .dev.vars und das Rate-Limiting-Binding verhalten sich also wie in der Produktion. Lokal setzt keine Cloudflare-Edge cf-connecting-ip, also zählt jeder Request als unknown und teilt sich ein Limit. Das ist zu erwarten. Um den echten Header zu sehen, deploye eine Preview mit wrangler versions upload und teste aus zwei Netzen.
Anderswo deployen
| Host | Client-IP | Limiter | Secrets |
|---|---|---|---|
| Cloudflare Workers | cf-connecting-ip | Rate-Limiting-Binding | wrangler secret put |
| Vercel | x-forwarded-for, von Vercel überschrieben | Redis | process.env |
| Netlify | x-nf-client-connection-ip | Redis | process.env |
Cloudflare dokumentiert das Binding als ungefähr und pro Standort gezählt, was für ein Kontaktformular in Ordnung ist. Außerhalb von Workers gibt es kein Modul cloudflare:workers, lies die Secrets also aus process.env und ersetze das Binding durch einen Redis-Zähler wie den auf der Seite zu Rate-Limits.
Ikiform die Prüfungen ausführen lassen
Wenn dir Schlüssel, ein Binding und eine CSP für ein einziges Formular zu viel sind, richte ein einfaches Formular auf deinen Ikiform-Endpunkt aus und lösche die Action:
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<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 />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<button>Send message</button>
</form>Nutze ein kleingeschriebenes <form>, nicht das <Form> von React Router, damit der Browser direkt an Ikiform sendet. Setze Spam check auf Ikiform: Ein Post ohne Token bekommt eine kurze Prüfseite auf ikiform.com, die Turnstile ausführt und die Felder weiterschickt. Wähle das Rate limit Strict, 2 alle 10 Minuten pro Besucher, und trag deine Domain unter Allowed domains ein. Abgefangener Spam bekommt dieselbe Antwort wie eine echte Nachricht und wird 30 Tage aufbewahrt. Die Anleitung zur Server-Einrichtung behandelt, wie du stattdessen deine eigene Action behältst.
Prüfen, ob es funktioniert
- Führe
npm run devaus und sende mit den Testschlüsseln eine Nachricht. Sie erscheint unter Submissions. Ein Workspace ohne Tarif nimmt 50localhost-Tests pro Monat an. - Sende eine zweite Nachricht, ohne neu zu laden. Nach der ersten Antwort erscheint ein frisches Widget, und der zweite Post gelingt.
- Deaktiviere JavaScript und sende ab. Die Seite lädt mit dem Fehler der Menschlichkeitsprüfung neu.
- Sende nach dem Deployment sechsmal in einer Minute. Der sechste Post bekommt
429mitRetry-After: 60. - 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 |
|---|---|
Server-only module referenced by client | Eine Komponente importiert aus spam.server.ts etwas, das sie rendert. Nur loader und action dürfen es verwenden. |
| Der zweite Versuch scheitert immer an siteverify | Das Widget hat sein benutztes Token behalten. Stell sicher, dass sich sein key nach jeder Fetcher-Antwort ändert. |
Invalid context value provided to handleRequest | Der Worker-Einstiegspunkt übergibt noch { cloudflare: { env, ctx } }, wie es die Templates für React Router 7 taten. Ruf den Handler nur mit dem Request auf und lies Bindings aus cloudflare:workers. |
Retry-After fehlt in der Antwort ohne JavaScript | Action-Header werden verworfen, außer der headers-Export gibt sie zurück. Kopiere sie wie gezeigt aus actionHeaders. |
Fragen
Rendere das Widget in der Route-Komponente, lies cf-turnstile-response in der Action aus den Formulardaten und sende es mit deinem Secret Key an siteverify. Mach nur weiter, wenn die Antwort success mit der Action lautet, die du am Widget gesetzt hast.
Deklariere ein Rate-Limiting-Binding in wrangler.jsonc und ruf ganz oben in der Action limit mit einem Schlüssel aus dem Header cf-connecting-ip auf. Gib bei einem Fehlschlag mit data() einen 429 zurück.
Bei einem Dokument-Request sendet React Router nur die Header, die der headers-Export deiner Route zurückgibt. Lies dort actionHeaders und kopiere, was du brauchst, etwa Retry-After.
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.