Spam im Kontaktformular stoppen7 Min. Lesezeit
Spamschutz für SvelteKit-Formulare mit Turnstile
Schütze ein SvelteKit-Formular vor Spam: eine Form-Action mit getClientAddress, ein Limit pro IP, Turnstile hinter use:enhance und eine CSP, die es erlaubt.
Führe die Prüfungen in einer Form-Action aus: Begrenze nach getClientAddress(), verifiziere das Turnstile-Token mit siteverify und leite dann die Felder mit dem Honeypot weiter. Rendere das Widget mit einer Svelte-Action und setze es im Callback von use:enhance zurück, weil jedes Token einmal funktioniert. Erlaube challenges.cloudflare.com in der Option csp des Plugins sveltekit() und sag SvelteKit bei adapter-node, welcher Proxy-Header die echte IP enthält.
Was in SvelteKit anders ist
SvelteKit gibt dir die IP des Besuchers direkt: getClientAddress() am Request-Event. Woher dieser Wert kommt, hängt vom Adapter ab, und bei adapter-node ist der Standard hinter jedem Proxy falsch. Stimmt das, passen die restlichen Prüfungen sauber in eine Form-Action.
- Die Form-Action fragt das Request-Event nach der Client-Adresse.
- SvelteKit überlässt die Antwort dem Adapter, mit dem du deployst.
- adapter-node nutzt die Remote-Adresse des Sockets, außer du setzt ADDRESS_HEADER und XFF_DEPTH.
- Die Adapter für Vercel, Netlify und Cloudflare lesen die Adresse, die ihre Plattform liefert, es gibt also nichts zu konfigurieren.
Das andere SvelteKit-Detail ist use:enhance. Es sendet ohne Neuladen der Seite, was für Menschen angenehmer ist, aber bedeutet, dass Turnstile nie von selbst neu rendert. Das Widget muss nach jeder Antwort von Hand zurückgesetzt werden.
Projekt-Setup
src/
env.ts
lib/
turnstile.ts
server/
spam.ts
routes/
contact/
+page.server.ts
+page.svelte
vite.config.ts
.envPUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_tokenimport { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
PUBLIC_TURNSTILE_SITE_KEY: { public: true },
TURNSTILE_SECRET_KEY: {},
IKIFORM_SECRET_KEY: {},
UPSTASH_REDIS_REST_URL: {},
UPSTASH_REDIS_REST_TOKEN: {},
});- SvelteKit 3 liest Variablen, die du in
src/env.tsdeklarierst. Nur der Site Key hatpublic: true, er ist also der einzige, den$app/env/publican den Browser geben kann. Alles in einemserver-Verzeichnis, etwasrc/lib/server, kann Client-Code überhaupt nicht importieren; SvelteKit lässt den Build scheitern, wenn du es versuchst. - Deklarierte Variablen werden beim Start der App gelesen, außer du setzt
static: true, ein rotierter Schlüssel braucht also einen Neustart, keinen neuen Build. Die$env/*-Module funktionieren weiterhin, sind in SvelteKit 3 aber veraltet. - Cloudflares Testschlüssel funktionieren auf jedem Host, auch auf
localhost.
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
sveltekit({
adapter: adapter(),
csp: {
mode: "auto",
directives: {
"script-src": ["self", "https://challenges.cloudflare.com"],
"frame-src": ["https://challenges.cloudflare.com"],
"object-src": ["none"],
"base-uri": ["self"],
},
},
}),
],
});SvelteKit 3 nimmt seine Optionen in vite.config.ts entgegen, übergeben an das Plugin sveltekit(); svelte.config.js wird nicht mehr gelesen. Mit mode: "auto" fügt SvelteKit seinen eigenen Inline-Skripten auf serverseitig gerenderten Seiten Nonces hinzu und auf vorgerenderten Seiten Hashes, die Policy braucht also kein 'unsafe-inline'. Turnstile lädt von seinem Host, den script-src erlaubt, und zeichnet ein iframe, das frame-src erlaubt.
Die reinen Server-Helfer
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";
const limiter = new Ratelimit({
redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "contact",
});
export async function secondsToWait(ip: string) {
const { success, reset } = await limiter.limit(ip);
return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}
export async function isHuman(token: string, ip: string, host: string) {
if (!token || token.length > 2048) return false;
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
});
const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
} catch {
return false;
}
}- Das Limit ist ein gleitendes Fenster in Redis, geteilt von allen Serverinstanzen, fünf Nachrichten pro Minute und IP.
resetkommt in Millisekunden zurück und wird zu ganzen Sekunden Wartezeit. devaus$app/envüberspringt die Hostnamen-Prüfung lokal, weil Test-Tokens einen Dummy-Hostnamen tragen.- Siteverify antwortet mit
timeout-or-duplicatebei einem Token, das älter als 300 Sekunden oder schon benutzt ist, deshalb setzt die Seite das Widget zurück.
Die Form-Action
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress, url }) => {
const ip = getClientAddress();
const wait = await secondsToWait(ip);
if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });
const form = await request.formData();
const values = {
name: String(form.get("name") ?? "").trim(),
email: String(form.get("email") ?? "").trim(),
message: String(form.get("message") ?? "").trim(),
};
if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
}
form.delete("cf-turnstile-response");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: form,
}).catch(() => null);
if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
return { sent: true };
},
} satisfies Actions;- Das Limit läuft, bevor der Body geparst wird.
fail(429)hält den Besucher mit der Meldung auf der Seite. - Jeder andere Fehler gibt
valueszurück, damit sich die Eingaben nach einem Fehler wieder füllen. - Die ursprünglichen
FormDatawerden weitergeleitet, einschließlich Honeypot, ohne das Token. Ikiform bewertet einen ausgefüllten Honeypot und speichert die Nachricht als Spam. - Der
Origindes Besuchers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenloselocalhost-Tests zu erkennen.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Das Widget als Svelte-Action
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
interface Api {
render(container: HTMLElement, options: { sitekey: string; action: string }): 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 = SCRIPT;
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 turnstile(node: HTMLElement, sitekey: string) {
let api: Api | undefined;
load().then((loaded) => {
api = loaded;
node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
});
return {
destroy() {
if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
},
};
}
export async function resetTurnstile(node: HTMLElement | undefined) {
const id = node?.dataset.widgetId;
if (id) (await load()).reset(id);
}Eine Svelte-Action ist der natürliche Ort für ein Widget eines Drittanbieters: Sie läuft, wenn das Element gemountet wird, und destroy läuft, wenn es verschwindet. Die Widget-ID liegt am Element, die Seite kann also genau dieses Widget zurücksetzen.
Die Seite
<script lang="ts">
import { enhance } from "$app/forms";
import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
import { resetTurnstile, turnstile } from "#lib/turnstile.js";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let box = $state<HTMLDivElement>();
let sending = $state(false);
</script>
{#if form?.sent}
<p role="status">Thanks. We'll reply by email.</p>
{:else}
<form
method="POST"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
await resetTurnstile(box);
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
{#if form?.error}
<p role="alert">{form.error}</p>
{/if}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
{/if}update({ reset: false })übernimmt das Ergebnis und behält, was der Besucher eingegeben hat. Der Standard würde das Formular leeren, wie es ein Browser tut.resetTurnstileläuft nach jeder Antwort, ob erfolgreich oder nicht. Das benutzte Token ist weg, und ein frisches steht bereit.- Ohne JavaScript sendet das Formular weiterhin und die Action läuft weiterhin, aber es wird kein Widget gerendert, siteverify lehnt es also mit einer klaren Meldung ab.
- Eine Seite mit Form-Actions kann nicht vorgerendert werden, diese Route rendert also immer auf dem Server. Andere Seiten können vorgerendert bleiben.
- Die Klasse
.hpversteckt den Honeypot außerhalb des sichtbaren Bereichs, wie in der Honeypot-Anleitung.
Lokal testen
Unter vite dev steht kein Adapter vor der App, getClientAddress() gibt also deine Loopback-Adresse zurück, und alle lokalen Tabs teilen sich einen Bucket. Das reicht, um die Meldung zu prüfen, nicht aber die Proxy-Einrichtung. Um die zu testen, baue mit adapter-node und starte die Ausgabe mit denselben Variablen, die du in der Produktion nutzt:
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node buildSende ein paarmal mit verschiedenen X-Forwarded-For-Werten. Mit XFF_DEPTH=1 liest SvelteKit die Adresse ganz rechts, die dein Proxy anhängen würde, ein Wert, den der Client links erfindet, ändert also nichts.
Deployment
Auf deinem eigenen Server mit adapter-node hinter einem Proxy sag SvelteKit, wo die echte IP steht:
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1Die SvelteKit-Doku warnt, dass Header leicht zu fälschen sind und dass das Lesen der Adresse ganz links in X-Forwarded-For der klassische Fehler ist. XFF_DEPTH zählt vertrauenswürdige Proxys von rechts, setze es also auf die Zahl der Proxys, die du betreibst, und stell sicher, dass die App nicht an ihnen vorbei erreichbar ist. SvelteKit 3 hat die Variable ORIGIN von adapter-node entfernt. Wenn dein Proxy den ursprünglichen Host nicht weitergibt, setze paths.origin in den sveltekit()-Optionen, damit SvelteKits eigene Formularprüfung deine öffentliche URL kennt.
| Adapter | Client-IP | Secrets |
|---|---|---|
adapter-node | ADDRESS_HEADER und XFF_DEPTH | Prozessumgebung |
adapter-vercel | Automatisch | Umgebungsvariablen des Projekts |
adapter-netlify | Automatisch | Umgebungsvariablen der Site |
adapter-cloudflare | Automatisch | wrangler secret put |
Ikiform die Prüfungen ausführen lassen
Um nichts davon selbst zu betreiben, lösche +page.server.ts und richte die action des Formulars ohne use:enhance auf deinen Ikiform-Endpunkt aus. Die Seite kann dann wie der Rest einer statischen Site vorgerendert werden.
Setze Spam check des Formulars auf Ikiform, damit ein Post ohne Token eine kurze Prüfseite auf ikiform.com bekommt, 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. Ikiform bewahrt abgefangenen Spam 30 Tage auf, ein Fehlalarm ist also mit einem Klick wiederhergestellt. Die SvelteKit-Einrichtungsanleitung behandelt Endpunkt und Schlüssel.
Prüfen, ob es funktioniert
- Führe
vite devmit den Testschlüsseln aus und sende eine Nachricht. Sie erscheint unter Submissions. Ein Workspace ohne Tarif nimmt 50localhost-Tests pro Monat an. - Sende eine zweite Nachricht, ohne neu zu laden. Sie geht durch, was zeigt, dass das Zurücksetzen des Widgets funktioniert hat.
- Sende sechs Nachrichten innerhalb einer Minute. Die sechste zeigt, wie viele Sekunden du warten musst.
- Logge hinter deinem Proxy
getClientAddress()aus zwei Netzen. Du siehst zwei verschiedene Adressen, nicht die deines Proxys. - 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 |
|---|---|
Auf adapter-node werden alle Besucher gemeinsam begrenzt | getClientAddress() gibt die Adresse deines Proxys zurück. Setze ADDRESS_HEADER und XFF_DEPTH. |
| Das zweite Absenden scheitert immer an der Menschlichkeitsprüfung | Das Widget hat sein benutztes Token behalten. Ruf im Callback von use:enhance nach update die Funktion resetTurnstile auf. |
| Der Build scheitert, weil eine Seite mit Actions vorgerendert wird | Die Kontakt-Route hat prerender = true von einem Layout geerbt. Seiten mit Actions können nicht vorgerendert werden; setze export const prerender = false in ihrer +page.server.ts. |
| Der Bereich des Widgets ist leer und die Konsole meldet einen CSP-Verstoß | In script-src oder frame-src fehlt https://challenges.cloudflare.com in csp.directives in vite.config.ts. |
Fragen
Ruf getClientAddress() am Request-Event in einer Form-Action, Load-Funktion oder einem Hook auf. Der Adapter liefert sie. Bei adapter-node hinter einem Proxy setze ADDRESS_HEADER und XFF_DEPTH, sonst bekommst du die Adresse des Proxys.
use:enhance lädt die Seite nicht neu, das Widget behält also das Token, das es schon benutzt hat. Setze das Widget im enhance-Callback nach jeder Antwort zurück.
Ja. Füge https://challenges.cloudflare.com zu script-src und frame-src in csp.directives hinzu, die SvelteKit 3 in den Optionen des Plugins sveltekit() in vite.config.ts entgegennimmt. Mit mode auto fügt SvelteKit Nonces oder Hashes für seine eigenen Skripte hinzu.
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.