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

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

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.

Diagramm: Die Middleware-Kette vor der TanStack-Start-Serverfunktion

1. Der Browser oder ein Skript sendet an die URL der Serverfunktion.
2. Die CSRF-Middleware von Start lehnt Posts ab, bei denen sie nicht nachweisen kann, dass sie von deinem eigenen Origin kommen.
3. Die Besucher-Middleware liest Client-IP und Origin-Header und gibt sie als Context weiter.
4. Der Validator normalisiert die Eingabe, bevor der Handler sie sieht.
5. Der Handler nimmt ein Token aus dem Bucket dieser IP. Ist keins mehr übrig, gibt er ein Später-erneut-versuchen-Ergebnis zurück.
6. Er verifiziert das Turnstile-Token und leitet dann die Felder an Ikiform weiter, das sie speichert und den Honeypot bewertet.

Jede Schicht ist günstiger als die nächste. Ein abgelehnter Post erreicht nie siteverify oder Ikiform.

## Projekt-Setup

`Dateien`:

```text
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
```

`.env`:

```bash
VITE_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 über `import.meta.env`. Die Secrets haben kein Präfix, existieren also nur auf dem Server als `process.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

`src/server/visitor.ts`:

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

`src/server/bucket.ts`:

```ts
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](https://ikiform.com/de/guides/spam-protection/rate-limiting.md) zeigt, und behalte dieselbe Form `take(key)`.

## Die Serverfunktion

`src/server/support.ts`:

```ts
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.
- `action` muss `support` sein. Ein Token, das mit deinem Site Key für ein anderes Widget erzeugt wurde, wird abgelehnt.
- Der Honeypot `_gotcha` wird weitergeleitet, nicht hier geprüft, ein ausgefüllter wird also als Spam gespeichert, wo du ihn prüfen kannst.

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

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

`src/lib/use-turnstile.ts`:

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

`src/routes/support.tsx`:

```tsx
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 `.url` einfache 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:

`src/start.ts`:

```ts
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`, das `frame-src` erlauben muss.
- Der CSRF-`filter` beschrä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:

`src/routes/support.tsx`:

```ts
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](https://ikiform.com/de/docs/guides/server.md) behandelt Schlüssel, falls du die Serverfunktion behältst.

## Prüfen, ob es funktioniert

1. 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.
2. Sende schnell vier Anfragen. Die vierte gibt die Später-erneut-versuchen-Meldung mit einer Sekundenzahl zurück.
3. Setze `TURNSTILE_SECRET_KEY` auf das Secret, das immer scheitert. Jede Anfrage zeigt den Fehler der Menschlichkeitsprüfung.
4. Wiederhole den Aufruf der Serverfunktion aus dem Netzwerk-Tab mit `curl` und ohne `Origin`. Die CSRF-Middleware lehnt ihn ab.
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 |
| --- | --- |
| 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

### Wie bekomme ich die Client-IP in einer TanStack-Start-Serverfunktion?

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.

### Haben TanStack-Start-Serverfunktionen CSRF-Schutz?

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.

### Kann ich TanStack Start im Arbeitsspeicher begrenzen?

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.

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