# 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.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/spam-protection/sveltekit

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.

Diagramm: Woher getClientAddress() bei jedem Adapter die IP des Besuchers bekommt

1. Die Form-Action fragt das Request-Event nach der Client-Adresse.
2. SvelteKit überlässt die Antwort dem Adapter, mit dem du deployst.
3. adapter-node nutzt die Remote-Adresse des Sockets, außer du setzt ADDRESS_HEADER und XFF_DEPTH.
4. Die Adapter für Vercel, Netlify und Cloudflare lesen die Adresse, die ihre Plattform liefert, es gibt also nichts zu konfigurieren.

Hinter nginx oder einem Load Balancer sieht adapter-node für jeden Besucher die Adresse des Proxys, bis du ADDRESS_HEADER setzt, und eine gemeinsame Adresse macht aus einem Limit pro IP eines für die ganze Site.

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

`Dateien`:

```text
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
```

`.env`:

```bash
PUBLIC_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_token
```

`src/env.ts`:

```ts
import { 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.ts` deklarierst. Nur der Site Key hat `public: true`, er ist also der einzige, den `$app/env/public` an den Browser geben kann. Alles in einem `server`-Verzeichnis, etwa `src/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`.

`vite.config.ts`:

```ts
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

`src/lib/server/spam.ts`:

```ts
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. `reset` kommt in Millisekunden zurück und wird zu ganzen Sekunden Wartezeit.
- `dev` aus `$app/env` überspringt die Hostnamen-Prüfung lokal, weil Test-Tokens einen Dummy-Hostnamen tragen.
- Siteverify antwortet mit `timeout-or-duplicate` bei einem Token, das älter als 300 Sekunden oder schon benutzt ist, deshalb setzt die Seite das Widget zurück.

## Die Form-Action

`src/routes/contact/+page.server.ts`:

```ts
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 `values` zurück, damit sich die Eingaben nach einem Fehler wieder füllen.
- Die ursprünglichen `FormData` werden weitergeleitet, einschließlich Honeypot, ohne das Token. Ikiform bewertet einen ausgefüllten Honeypot und speichert die Nachricht als Spam.
- Der `Origin` des Besuchers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose `localhost`-Tests zu erkennen.

> 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](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `IKIFORM_SECRET_KEY` in reinen Server-Umgebungsvariablen.

## Das Widget als Svelte-Action

`src/lib/turnstile.ts`:

```ts
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

`src/routes/contact/+page.svelte`:

```svelte
<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.
- `resetTurnstile` lä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 `.hp` versteckt den Honeypot außerhalb des sichtbaren Bereichs, wie in der [Honeypot-Anleitung](https://ikiform.com/de/guides/spam-protection/honeypot.md).

## 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:

`Terminal`:

```bash
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build
```

Sende 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:

`Umgebung`:

```bash
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1
```

Die 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](https://ikiform.com/de/docs/guides/sveltekit.md) behandelt Endpunkt und Schlüssel.

## Prüfen, ob es funktioniert

1. Führe `vite dev` mit den Testschlüsseln aus und sende eine Nachricht. Sie erscheint unter **Submissions**. Ein Workspace ohne Tarif nimmt 50 `localhost`-Tests pro Monat an.
2. Sende eine zweite Nachricht, ohne neu zu laden. Sie geht durch, was zeigt, dass das Zurücksetzen des Widgets funktioniert hat.
3. Sende sechs Nachrichten innerhalb einer Minute. Die sechste zeigt, wie viele Sekunden du warten musst.
4. Logge hinter deinem Proxy `getClientAddress()` aus zwei Netzen. Du siehst zwei verschiedene Adressen, nicht die deines Proxys.
5. 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

### Wie bekomme ich die Client-IP in SvelteKit?

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.

### Warum scheitert Turnstile mit use:enhance beim zweiten Absenden?

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.

### Funktioniert die CSP von SvelteKit mit Turnstile?

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.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Spam im Kontaktformular stoppen](https://ikiform.com/de/guides/spam-protection.md): Stopp Kontaktformular-Spam mit Schichten, die echte Menschen nichts kosten: Honeypot, Timing, Inhaltsbewertung, Rate-Limits und eine Menschlichkeitsprüfung nur bei Bedarf.
- [Cloudflare Turnstile](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md): Füge Cloudflare Turnstile zu einem einfachen HTML-Formular hinzu: das Widget, serverseitiges siteverify, Testschlüssel und ein Weg ganz ohne eigene Schlüssel oder Server.
- [Rate-Limits](https://ikiform.com/de/guides/spam-protection/rate-limiting.md): Begrenze Formulareinsendungen pro Besucher mit einem gemeinsamen Speicher, antworte mit 429 und Retry-After und sperre keine Büros aus, die sich eine IP teilen.
- [Ohne Captcha](https://ikiform.com/de/guides/spam-protection/without-captcha.md): Stopp den meisten Formular-Spam ganz ohne Captcha: Honeypot, signierte Render-Zeit, Inhaltsbewertung und Rate-Limit, zusammen statt einzeln eingesetzt.
- [Honeypot-Feld](https://ikiform.com/de/guides/spam-protection/honeypot.md): Füge jedem Formular einen Honeypot hinzu, der Bots abfängt, ohne Screenreader, Tastatur oder Autofill zu stören, mit der Serverprüfung für jeden Stack.
- [Alternativen zu reCAPTCHA](https://ikiform.com/de/guides/spam-protection/recaptcha-alternatives.md): Vergleiche reCAPTCHA-Alternativen für Formulare, von Turnstile und hCaptcha bis Proof-of-Work und ganz ohne Captcha, und wechsle mit einer kleinen, sicheren Änderung.
