# Spamschutz für Next.js-Formulare mit Turnstile

> Schütze ein Next.js-Formular vor Spam: Honeypot, Turnstile in einer Server Action geprüft, ein Limit pro IP auf Vercel, Netlify oder Cloudflare und eine passende CSP.

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

Prüfe in Next.js auf Spam in der Server Action, weil die Action ein öffentlicher POST-Endpunkt ist, den jeder ohne dein Formular aufrufen kann. Begrenze nach der Client-IP, die dein Host setzt, verifiziere das Turnstile-Token mit siteverify und leite dann die Felder mit intaktem Honeypot weiter. Lade das Turnstile-Skript über `next/script` mit deiner CSP-Nonce und erlaube `challenges.cloudflare.com` in `frame-src`.

## Was in Next.js anders ist

Eine Server Action sieht aus wie ein Funktionsaufruf, aber Next.js macht daraus einen POST-Endpunkt mit einer ID im JavaScript der Seite. Ein Bot braucht dein Formular nicht. Er kann diesen POST mit beliebigen Feldern wiederholen, so schnell er will. Jede Prüfung gehört also in die Action, nie nur in die Komponente.

Drei Details von Next.js prägen den Rest dieser Seite:

- **Es gibt kein `request.ip`.** Die Client-IP kommt aus einem Header, und welchem du trauen kannst, hängt vom Host ab.
- **Eine Nonce bedeutet dynamisches Rendern.** Eine Nonce pro Request kann nicht für eine Seite gelten, die zur Build-Zeit gebaut wurde.
- **Client-Navigation hält die Seite am Leben.** Ein einmal geladenes Turnstile-Skript rendert nach einer Soft-Navigation kein Widget mehr.

Diagramm: Die Reihenfolge der Prüfungen in der Next.js-Server-Action

1. Das Formular oder ein Skript, das es nachahmt, sendet an die Server Action.
2. Die Action prüft den Zähler des Besuchers gegen eine Vercel-Firewall-Regel. Über dem Limit gibt sie einen Fehlerzustand zurück und hört auf.
3. Sie sendet das Turnstile-Token und die Client-IP an siteverify und prüft Action-Name und Hostname in der Antwort.
4. Erst dann leitet sie die Felder, Honeypot eingeschlossen, an Ikiform weiter, das die Nachricht bewertet und speichert.

Die günstigste Prüfung läuft zuerst. Der Honeypot wird unverändert weitergereicht, ein ausgefüllter wird also als Spam gespeichert statt still verworfen.

## Projekt-Setup

Die Dateien, die diese Seite einem App-Router-Projekt hinzufügt:

`Dateien`:

```text
app/
  contact/
    page.tsx
    contact-form.tsx
    actions.ts
lib/
  client-ip.ts
  turnstile.ts
proxy.ts
.env.local
```

Leg im Cloudflare-Dashboard ein Turnstile-Widget an und trag jeden Hostnamen ein, auf dem das Formular läuft. Nutze beim Bauen die Testschlüssel von Cloudflare, die auf jedem Host bestehen, auch auf `localhost`:

`.env.local`:

```bash
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_HOSTNAME=
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-real-ip
```

- Nur `NEXT_PUBLIC_TURNSTILE_SITE_KEY` erreicht den Browser. Next.js setzt `NEXT_PUBLIC_`-Werte zur Build-Zeit ein, eine Änderung des Site Keys braucht also einen neuen Build.
- `TURNSTILE_HOSTNAME` bleibt lokal leer, weil Test-Tokens deinen Hostnamen nicht enthalten. Setze es in der Produktion auf deine echte Domain.
- Tausche das Secret gegen `2x0000000000000000000000000000000AA`, um jede Prüfung scheitern zu sehen.

## Die Client-IP lesen, die dein Host setzt

Ein Rate-Limit, das auf einem Header beruht, den der Besucher schreiben kann, ist kein Limit. Jeder Host setzt einen Header, den er beim Eingang überschreibt, und nur diesem kannst du trauen:

Diagramm: Welcher Header bei welchem Host die echte Client-IP enthält

1. clientIp() liest den einen Header, der in CLIENT_IP_HEADER steht, und ignoriert den Rest.
2. Vercel überschreibt x-forwarded-for und x-real-ip mit der Adresse des Besuchers.
3. Netlify legt die Adresse in x-nf-client-connection-ip ab.
4. Cloudflare, auch für Next.js, das mit OpenNext deployt wird, setzt cf-connecting-ip.

Vercel dokumentiert, dass es X-Forwarded-For überschreibt, um Spoofing zu verhindern. Netlifys Dokumentation nennt in Functions nur context.ip; x-nf-client-connection-ip ist der Header, auf den Netlify-Mitarbeiter verweisen.

`lib/client-ip.ts`:

```ts
import "server-only";
import { headers } from "next/headers";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-real-ip";

export async function clientIp() {
  const value = (await headers()).get(HEADER);
  return value?.split(",")[0]?.trim() || null;
}
```

## Turnstile in einem reinen Server-Helfer verifizieren

`lib/turnstile.ts`:

```ts
import "server-only";

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

export type Check = { ok: true } | { ok: false; reason: string };

export async function verifyTurnstile(token: string, ip: string | null): Promise<Check> {
  if (!token || token.length > 2048) return { ok: false, reason: "missing-input-response" };

  let verdict: Verdict;
  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 ?? undefined }),
      signal: AbortSignal.timeout(5000),
    });
    verdict = (await response.json()) as Verdict;
  } catch {
    return { ok: false, reason: "siteverify-unreachable" };
  }

  if (!verdict.success) return { ok: false, reason: verdict["error-codes"]?.join(",") || "failed" };
  if (verdict.action !== "contact") return { ok: false, reason: "wrong-action" };
  const expected = process.env.TURNSTILE_HOSTNAME;
  if (expected && verdict.hostname !== expected) return { ok: false, reason: "wrong-hostname" };
  return { ok: true };
}
```

- Ein Token ist 300 Sekunden gültig und kann einmal verifiziert werden, danach gibt siteverify `timeout-or-duplicate` zurück.
- `success` allein reicht nicht. Ein Token, das mit deinem Site Key auf einer anderen Site erzeugt wurde, sagt ebenfalls `success`, daher prüft der Helfer `action` und in der Produktion `hostname`.
- Er schlägt sicher fehl. Ist Cloudflare nicht erreichbar, sieht der Besucher einen Fehler, und kein Bot kommt durch.

## Die Server Action

`app/contact/actions.ts`:

```ts
"use server";

import { checkRateLimit } from "@vercel/firewall";
import { headers } from "next/headers";
import { clientIp } from "@/lib/client-ip";
import { verifyTurnstile } from "@/lib/turnstile";

export interface ContactState {
  status: "idle" | "sent" | "error";
  message: string;
  fields?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const fields = {
    name: String(formData.get("name") ?? "").trim(),
    email: String(formData.get("email") ?? "").trim(),
    message: String(formData.get("message") ?? "").trim(),
  };

  if (process.env.VERCEL) {
    const { rateLimited } = await checkRateLimit("contact-form", { headers: await headers() });
    if (rateLimited) return { status: "error", message: "Too many messages from your connection. Try again in a minute.", fields };
  }

  const check = await verifyTurnstile(String(formData.get("cf-turnstile-response") ?? ""), await clientIp());
  if (!check.ok) {
    console.warn("turnstile", check.reason);
    return { status: "error", message: "We couldn't confirm you're human. Try the check again.", fields };
  }

  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}`,
      Origin: (await headers()).get("origin") ?? "",
    },
    body: JSON.stringify({ ...fields, _gotcha: String(formData.get("_gotcha") ?? "") }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We'll reply by email." };
  return { status: "error", message: result?.error?.message ?? "Your message didn't send. Try again.", fields };
}
```

Auf Vercel kommt das Limit von der Firewall. Öffne die **Firewall** deines Projekts, füge eine Regel mit der Bedingung `@vercel/firewall` hinzu, setze die **Rate limit ID** auf `contact-form` und wähle ein Fenster wie 5 Requests pro Minute. `checkRateLimit` nimmt die Request-Header, funktioniert also in einer Server Action, in der es kein `Request`-Objekt gibt, und gruppiert nach Client-IP, außer du übergibst einen `rateLimitKey`.

- Vercel zählt jede Regel pro Region, Traffic über mehrere Regionen kann die eingestellte Zahl also überschreiten. Für ein Kontaktformular ist das in Ordnung; für eine exakte globale Zählung nutze Redis, wie auf der [Seite zu Rate-Limits](https://ikiform.com/de/guides/spam-protection/rate-limiting.md).
- Der `Origin`-Header des Besuchers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose `localhost`-Tests zu erkennen.
- Außerhalb von Vercel wird der Firewall-Aufruf übersprungen. Setze dort einen Redis- oder Workers-Limiter ein, mit `clientIp()` als Schlüssel.

> 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 Formular und das Widget

`app/contact/contact-form.tsx`:

```tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useRef } from "react";
import { type ContactState, sendMessage } from "./actions";

interface Turnstile {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: Turnstile;
  }
}

const initial: ContactState = { status: "idle", message: "" };

export function ContactForm({ nonce }: { nonce?: string }) {
  const [state, action, pending] = useActionState(sendMessage, initial);
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mount() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!,
      action: "contact",
    });
  }

  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  useEffect(
    () => () => {
      if (widget.current) window.turnstile?.remove(widget.current);
      widget.current = null;
    },
    [],
  );

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.fields?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.fields?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.fields?.message} />
      <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>
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" nonce={nonce} onReady={mount} />
    </form>
  );
}
```

- `?render=explicit` verhindert, dass das Skript die Seite nach Widgets durchsucht. `onReady` läuft, sobald das Skript geladen ist, und erneut bei jedem Mounten der Komponente, was clientseitige Navigation abdeckt.
- Ein Token funktioniert einmal. Wenn die Action einen Fehler zurückgibt, setzt der Effect das Widget zurück, damit der nächste Versuch ein frisches Token mitbringt.
- Das Formular sendet auch vor der Hydration, weil `action` die Server Action selbst ist. Ohne JavaScript gibt es kein Widget, also lehnt siteverify den Post ab; die Menschlichkeitsprüfung braucht einen Browser.
- Die Klasse `.hp` aus der [Honeypot-Anleitung](https://ikiform.com/de/guides/spam-protection/honeypot.md) versteckt den Honeypot außerhalb des sichtbaren Bereichs.

`app/contact/page.tsx`:

```tsx
import { headers } from "next/headers";
import { ContactForm } from "./contact-form";

export default async function Page() {
  const nonce = (await headers()).get("x-nonce") ?? undefined;

  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm nonce={nonce} />
    </main>
  );
}
```

## Eine CSP, die Turnstile laufen lässt

Next.js liest die Nonce aus dem Request-Header `Content-Security-Policy` und fügt sie seinen eigenen Skripten und jedem `<Script>` mit der Prop `nonce` hinzu. Laut Cloudflare reicht Turnstile die Nonce an das weiter, was es lädt, und sein iframe braucht `frame-src https://challenges.cloudflare.com`. In Next.js 16 heißt die Datei `proxy.ts`, die `middleware.ts` ersetzt hat:

`proxy.ts`:

```ts
import { type NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
  const dev = process.env.NODE_ENV === "development";
  const policy = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://challenges.cloudflare.com${dev ? " 'unsafe-eval'" : ""}`,
    "frame-src https://challenges.cloudflare.com",
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", policy);
  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", policy);
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
```

- Mit `'strict-dynamic'` vertrauen moderne Browser der Nonce und ignorieren den Host, der für ältere Browser bleibt.
- Wenn die Seite `headers()` liest, wird sie pro Request gerendert, was eine Nonce voraussetzt. Eine statisch gebaute Kontaktseite kann keine tragen; nutze stattdessen eine Policy ohne Nonces in `headers()` von `next.config`, zum Preis von `'unsafe-inline'`.

## Deployment

| Host | CLIENT_IP_HEADER | Rate-Limit | Hinweise |
| --- | --- | --- | --- |
| Vercel | `x-real-ip` | Firewall-Regel und `checkRateLimit` | Füge pro Umgebung, in der du testest, eine Regel hinzu |
| Netlify | `x-nf-client-connection-ip` | Redis, etwa Upstash | Server Actions laufen in einer Netlify-Funktion |
| Cloudflare, mit OpenNext | `cf-connecting-ip` | Rate-Limiting-Binding von Workers | Das Binding ist ungefähr und wird pro Standort gezählt |

Setze `TURNSTILE_HOSTNAME` in der Produktion auf deine echte Domain. Preview-Deployments bekommen eigene Hostnamen, also füge sie dem Widget hinzu oder lass die Prüfung in Previews aus.

## Ikiform die Prüfungen ausführen lassen

All das oben gibt es nur, weil die Server Action mit einem Secret Key weiterleitet, was die eigene Menschlichkeitsprüfung und das Limit pro Besucher von Ikiform abschaltet. Sende stattdessen aus dem Browser, dann führt Ikiform Honeypot, Turnstile und Rate-Limit für dich aus. Es gibt keine Cloudflare-Schlüssel, keine Änderungen an `proxy.ts` außer dem Erlauben von `https://ikiform.com` in `frame-src` und `connect-src` und keine Action:

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { challenge, submit } from "@ikiform/sdk";
import { useActionState, useRef } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const frame = useRef<HTMLDivElement>(null);
  const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
    try {
      const turnstileToken = await challenge(FORM_ID, { container: frame.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      return result.ok ? "sent" : result.error.message;
    } catch {
      return "The human check didn't finish. Try again.";
    }
  }, "");

  if (message === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form action={action}>
      <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} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={frame} />
      {message ? <p role="alert">{message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Setze **Spam check** des Formulars auf **Ikiform** und sein **Rate limit** auf Strict, 2 alle 10 Minuten pro Besucher, und füge deine Domain plus `localhost` unter **Allowed domains** hinzu. `challenge()` bindet einen kleinen ikiform.com-Frame ein, der unsichtbar bleibt, außer Turnstile braucht einen Klick. Spam bekommt dieselbe Erfolgsantwort wie eine echte Nachricht, wird mit den Gründen gespeichert, aus denen er abgefangen wurde, und 30 Tage aufbewahrt, bevor er gelöscht wird. Die [Next.js-Einrichtungsanleitung](https://ikiform.com/de/docs/guides/nextjs.md) behandelt das Anlegen des Formulars und seiner Schlüssel.

## Prüfen, ob es funktioniert

1. Starte `next dev` mit den Testschlüsseln und sende eine Nachricht. Sie kommt unter **Submissions** an. Ohne Tarif nimmt ein Workspace 50 Testeinsendungen pro Monat von `localhost` an.
2. Setze `TURNSTILE_SECRET_KEY` auf das Secret, das immer scheitert, und sende ab. Das Formular zeigt den Fehler der Menschlichkeitsprüfung und behält deinen Text.
3. Sende auf einer Vercel-Preview mit der Firewall-Regel sechsmal in einer Minute ab. Beim sechsten Mal erscheint die Limit-Meldung.
4. 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.
5. Öffne die Seite mit sichtbarer Konsole. Es gibt keine CSP-Verstöße für `challenges.cloudflare.com`.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| Das Widget erscheint nach der Navigation zur Seite nie | Das Skript wurde auf einer früheren Seite geladen und durchsucht die Seite nicht erneut. Rendere aus `onReady` heraus, wie im Beispiel, nicht aus `onLoad`. |
| Jede Nachricht scheitert in der Produktion an der Menschlichkeitsprüfung | `TURNSTILE_HOSTNAME` passt nicht zum Hostnamen, den siteverify zurückgibt, oft `www.` gegenüber der Apex-Domain. Logge `check.reason`, um zu sehen, welcher es ist. |
| Alle Besucher teilen sich einen Rate-Limit-Bucket | `CLIENT_IP_HEADER` nennt einen Header, den dein Host nicht setzt, also gibt `clientIp()` null zurück. Wähle den Header aus der Tabelle oben. |
| `checkRateLimit` gibt `error: "not-found"` zurück | In dieser Umgebung hat keine Firewall-Regel die Rate limit ID `contact-form`. Füge sie hinzu und veröffentliche dann die Firewall-Änderungen. |

## Fragen

### Wie füge ich Cloudflare Turnstile zu einer Next.js-Server-Action hinzu?

Rendere das Widget in einer Client-Komponente, lade das Skript mit next/script und lies das Feld cf-turnstile-response in der Server Action. Sende es mit deinem Secret Key an siteverify und mach nur weiter, wenn die Antwort success mit der erwarteten Action und dem erwarteten Hostnamen lautet.

### Wie bekomme ich die Client-IP in einer Next.js-Server-Action?

Lies sie mit headers() aus next/headers aus dem Header, den dein Host setzt: x-real-ip oder x-forwarded-for auf Vercel, x-nf-client-connection-ip auf Netlify, cf-connecting-ip auf Cloudflare. Next.js hat im App Router kein request.ip.

### Verhindert eine CSP-Nonce, dass die Kontaktseite statisch ist?

Ja. Eine Nonce wird pro Request erzeugt, die Seite muss also bei jedem Request gerendert werden. Halte die Kontaktseite dynamisch oder nutze eine Policy ohne Nonces, die challenges.cloudflare.com per Host erlaubt.

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