# So stoppst du Spam über dein Kontaktformular

> Stopp Kontaktformular-Spam mit Schichten, die echte Menschen nichts kosten: Honeypot, Timing, Inhaltsbewertung, Rate-Limits und eine Menschlichkeitsprüfung nur bei Bedarf.

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

Staple günstige, unsichtbare Prüfungen, bevor du irgendeine Hürde einbaust: ein verstecktes Honeypot-Feld, eine Mindestzeit zum Ausfüllen des Formulars, eine Bewertung für Links und Spam-Phrasen und ein Rate-Limit pro Besucher. Füge eine Menschlichkeitsprüfung wie Cloudflare Turnstile erst hinzu, wenn trotzdem Spam durchkommt, und verifiziere ihr Token auf einem Server. Speichere, was du abfängst, statt es wegzuwerfen, damit du die gelegentliche echte Nachricht retten kannst.

Ein Kontaktformular auf einer öffentlichen Seite bekommt innerhalb weniger Tage nach der Indexierung Müll. Das meiste davon zielt nicht auf dich. Skripte durchsuchen das Web nach jedem `<form>`, füllen jedes Eingabefeld aus und senden es ab, in der Hoffnung, dass irgendwo ein Link landet. Ein kleinerer Teil kommt von echten Browsern, die von Software gesteuert werden, und manches von Menschen, die dafür bezahlt werden, denselben Pitch in Tausende Formulare zu kopieren. Jede Art wird von einer anderen Prüfung gestoppt, deshalb löst ein einzelner Trick das Problem nie.

Diese Anleitung erklärt jede Abwehr, was sie abfängt und was sie echte Besucher kostet, mit Code, den du auf jedem Stack ausführen kannst. Dann zeigt sie, wie Ikiform, ein Formular-Backend, dieselben Schichten für dich ausführt.

## Schichten, die günstigste zuerst

Ordne die Prüfungen nach ihren Kosten. Requests zu zählen ist nahezu kostenlos, also läuft das, bevor irgendetwas den Body liest. Prüfungen, die einen anderen Dienst aufrufen, etwa ein Captcha, laufen erst, wenn die günstigen bestanden sind. Die Inhaltsbewertung kommt zuletzt, weil sie die geparsten Felder braucht. Ikiform führt seine Prüfungen bei jedem Post in dieser Reihenfolge aus.

Diagramm: Die Reihenfolge der Spamprüfungen, vom eintreffenden Post bis zu gespeichert oder Spam

1. Ein Post trifft am Formular-Endpunkt ein.
2. Das Rate-Limit zählt zuerst die Posts pro Besucher. Eine Flut wird mit 429 abgelehnt, bevor der Body gelesen wird.
3. Erlaubte Domains vergleichen die Seite, von der der Post kam, mit den Domains, die du eingetragen hast.
4. Ist die Menschlichkeitsprüfung an, muss der Post ein gültiges Turnstile-Token enthalten.
5. Der Honeypot erhöht den Score um 5, wenn er ausgefüllt ist.
6. Timing, Links, bekannte Spam-Phrasen, Wegwerfadressen und Wiederholungen erhöhen ihn um kleinere Beträge.
7. Ab 5 wird der Post als Spam gespeichert. Darunter wird er angenommen, und deine E-Mail und Webhooks gehen raus.

Die obere Reihe lehnt Posts ab. Die untere Reihe lehnt nie ab: Sie bewertet, sodass ein Fehlalarm einen Klick zum Wiederherstellen kostet, keine verlorene Nachricht.

## Die Abwehrmaßnahmen im Vergleich

Jede Abwehr hat ihren Preis. Die Tabelle sagt ehrlich, was jede verpasst und was sie von den Leuten verlangt, von denen du hören willst.

| Abwehr | Stoppt | Verpasst | Kosten für echte Besucher |
| --- | --- | --- | --- |
| [Honeypot](https://ikiform.com/de/guides/spam-protection/honeypot.md) | Skripte, die jedes Eingabefeld ausfüllen | Bots, die CSS lesen oder versteckte Felder überspringen, und Menschen | Keine, wenn es vor Screenreadern und Autofill verborgen ist |
| Zeitfalle | Posts, die schneller gesendet werden, als jemand tippen kann | Langsame Bots und Bots, die warten | Keine, solange das Minimum ein paar Sekunden beträgt |
| Inhaltsbewertung, etwa Linkanzahl, Spam-Phrasen oder Akismet | SEO-Pitches, Link-Drops, Casino- und Pillen-Spam | Sauber aussehenden Spam, und sie kann eine echte Nachricht mit Links markieren | Keine, wenn du prüfst, was sie abfängt |
| [Rate limiting](https://ikiform.com/de/guides/spam-protection/rate-limiting.md) | Fluten und Skripte in einer Schleife | Langsamen, verteilten Spam | Büros und Events hinter einer IP können an ein knappes Limit stoßen |
| Erlaubte Domains | Posts, die von anderswo direkt an deinen Endpunkt gesendet werden | Skripte, die den `Origin`-Header fälschen | Keine |
| [Cloudflare Turnstile](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md) | Die meisten automatisierten Browser | Bezahlte menschliche Spammer | Meist unsichtbar. Manchmal ein Klick, und es braucht ein Skript |
| [reCAPTCHA oder hCaptcha](https://ikiform.com/de/guides/spam-protection/recaptcha-alternatives.md) | Die meisten automatisierten Browser | Bezahlte menschliche Spammer und Lösungsdienste | Bilderrätsel für manche Besucher, plus ein Drittanbieter-Skript |

Fang mit den ersten fünf an. Sie sind unsichtbar und fangen den Großteil dessen ab, was eine kleine Site abbekommt. Füge eine Menschlichkeitsprüfung hinzu, wenn dir das Dashboard zeigt, dass trotzdem Spam durchkommt, nicht vorher: Sie ist die einzige Schicht mit Kosten, die jeder Besucher spüren kann. Die Seite zu [Spam stoppen ohne Captcha](https://ikiform.com/de/guides/spam-protection/without-captcha.md) zeigt, wie weit die unsichtbaren Schichten allein kommen.

## Lies den Spam, bevor du ihn bekämpfst

Sieh dir zehn Spam-Nachrichten an, bevor du eine Lösung wählst. Was sie gemeinsam haben, verrät dir, wer sie sendet, und das zeigt auf die Schicht, die sie stoppt.

Diagramm: Vom Aussehen des Spams zur Schicht, die ihn stoppt

1. Zufälliger Text in jedem Feld kommt von einem Skript, das alle Eingabefelder ausfüllt. Ein Honeypot fängt es ab.
2. Dutzende Posts pro Minute aus einer Quelle kommen von einem Skript in einer Schleife. Ein Rate-Limit stoppt es.
3. Saubere Posts, die innerhalb einer Sekunde nach dem Laden der Seite gesendet werden, kommen von einem Headless-Browser. Eine Zeitfalle oder eine Menschlichkeitsprüfung stoppt ihn.
4. Höfliche Pitches mit Links kommen von Menschen, die pro Formular bezahlt werden. Nur Inhaltsbewertung und deine eigene Durchsicht fangen sie ab.

Kein Captcha stoppt die letzte Zeile. Ein Mensch kann es lösen, also sind Inhaltsbewertung und ein wöchentlicher Blick in den Spam-Ordner die einzige Abwehr.

## Ein Kontaktformular mit eingebauten Schichten

Hier ist ein Kontaktformular, für jeden Stack gebaut. Jede Version hat den außerhalb des sichtbaren Bereichs platzierten Honeypot und eine Menschlichkeitsprüfung. Die reinen Browser-Versionen nutzen die verwaltete Turnstile-Prüfung von Ikiform, die keine Schlüssel braucht. Die Server-Versionen zeigen den Do-it-yourself-Weg: Dein Server verifiziert ein Turnstile-Token mit deinen eigenen Cloudflare-Schlüsseln und leitet dann die Felder weiter. Wähle unten deinen Stack; die Wahl gilt für jedes Codebeispiel in diesen Anleitungen.

Die Server-Tabs teilen sich zwei kleine Helfer. `verifyTurnstile` fragt Cloudflare, ob ein Token echt ist, und `sendToIkiform` leitet die Felder mit deinem Secret Key und dem `Origin`-Header des Besuchers weiter. Ein serverseitiges fetch sendet keinen eigenen `Origin`, und Ikiform liest ihn, um einen kostenlosen `localhost`-Test zu erkennen. Beide schlagen sicher fehl: Ist Cloudflare nicht erreichbar, sieht der Besucher einen Fehler statt eines stillen Durchwinkens.

`lib/spam.ts`:

```ts
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export interface Contact {
  name: string;
  email: string;
  message: string;
  _gotcha: string;
}

export function readContact(data: FormData): Contact {
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    message: String(data.get("message") ?? "").trim(),
    _gotcha: String(data.get("_gotcha") ?? ""),
  };
}

export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
  if (!input.token || input.token.length > 2048) return false;
  const body = new URLSearchParams({ secret: input.secret, response: input.token });
  if (input.ip) body.set("remoteip", input.ip);
  try {
    const response = await fetch(SITEVERIFY, { method: "POST", body });
    const result = (await response.json()) as { success?: boolean };
    return result.success === true;
  } catch {
    return false;
  }
}

export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${secretKey}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(contact),
    });
    const result = await response.json().catch(() => null);
    if (result?.ok) return { ok: true as const };
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.values(fields)[0]?.[0];
    return {
      ok: false as const,
      status: response.status,
      message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
    };
  } catch {
    return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
  }
}
```

**HTML**

Das einfache Formular braucht kein Skript. Setze **Spam check** des Ikiform-Formulars auf **Ikiform**, dann bekommt ein Post ohne Token eine kurze Prüfseite auf ikiform.com, die Turnstile ausführt und die Felder weiterschickt. Wer sie unsichtbar besteht, merkt nichts davon.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required maxlength="120" />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10" maxlength="5000"></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>

  <button>Send message</button>
</form>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

Der Honeypot liegt außerhalb des sichtbaren Bereichs statt hinter `display: none`, weil manche Bots Felder überspringen, die so versteckt sind. `aria-hidden` am Wrapper und `tabindex` am Eingabefeld halten Screenreader und Tastatur davon fern.

**Next.js**

Eine Server Action verifiziert das Turnstile-Token mit deinen eigenen Schlüsseln und leitet dann die Felder mit deinem Ikiform-Secret-Key weiter. Der Honeypot geht unverändert mit, sodass ein ausgefüllter als Spam gespeichert wird, wo du ihn prüfen kannst. Speichere die Helfer oben als `lib/spam.ts`. Setze `TURNSTILE_SECRET_KEY` und `IKIFORM_SECRET_KEY` als reine Server-Umgebungsvariablen und lass **Spam check** des Ikiform-Formulars auf **Off**: Dein Server hat die Menschlichkeitsprüfung bereits ausgeführt.

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { readContact, sendToIkiform, verifyTurnstile } from "@/lib/spam";

export interface ContactState {
  ok: boolean;
  message: string;
  values?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const contact = readContact(formData);
  const values = { name: contact.name, email: contact.email, message: contact.message };
  const incoming = await headers();
  const forwarded = incoming.get("x-forwarded-for");

  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: forwarded?.split(",")[0]?.trim(),
  });
  if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again.", values };

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, contact, incoming.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return { ok: false, message: result.message, values };
}
```

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

```tsx
"use client";

import { useActionState } from "react";
import { Turnstile } from "@/components/turnstile";
import { type ContactState, sendMessage } from "./actions";

const initial: ContactState = { ok: false, message: "" };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendMessage, initial);

  if (state.ok) 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.values?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.values?.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>
      <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} reset={state} />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

`components/turnstile.tsx`:

```tsx
"use client";

import { useEffect, useRef } from "react";

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

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

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    if (window.turnstile) return resolve(window.turnstile);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => window.turnstile && resolve(window.turnstile), { once: true });
  });
}

export function Turnstile({ siteKey, reset }: { siteKey: string; reset?: unknown }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    let active = true;
    loadTurnstile().then((turnstile) => {
      if (active && box.current) widgetId = turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    });
    return () => {
      active = false;
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey, reset]);

  return <div ref={box} />;
}
```

Das Widget wird explizit gerendert und bei jeder Rückgabe der Action neu gerendert, weil ein Turnstile-Token nur einmal funktioniert. `defaultValue` setzt nach einem Fehler den Text des Besuchers wieder ein, da React ein Formular nach dem Ausführen seiner Action zurücksetzt.

Mehr zu Next.js? Lies [Spamschutz für Next.js-Formulare mit Turnstile](https://ikiform.com/de/guides/spam-protection/nextjs.md).

**Astro**

Eine Astro Action validiert die Felder mit Zod, prüft das Token gegen `context.clientAddress` und leitet den Post weiter. Speichere die Helfer oben als `src/lib/spam.ts` und lies beide Schlüssel mit `getSecret` aus `astro:env/server`. Das Formular sendet ohne Client-JavaScript, daher lädt jeder Versuch die Seite neu, und das Widget wird frisch gerendert. Füge einen Adapter hinzu und behalte `prerender = false`. Setze `TURNSTILE_SECRET_KEY` und `IKIFORM_SECRET_KEY` als reine Server-Umgebungsvariablen und lass **Spam check** des Ikiform-Formulars auf **Off**: Dein Server hat die Menschlichkeitsprüfung bereits ausgeführt.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
import { sendToIkiform, verifyTurnstile } from "../lib/spam";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1).max(120),
      email: z.email(),
      message: z.string().trim().min(10).max(5000),
      _gotcha: z.string().nullish(),
      "cf-turnstile-response": z.string().nullish(),
    }),
    handler: async (input, context) => {
      const human = await verifyTurnstile({
        secret: getSecret("TURNSTILE_SECRET_KEY") ?? "",
        token: input["cf-turnstile-response"] ?? "",
        ip: context.clientAddress,
      });
      if (!human) throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try the check again." });

      const result = await sendToIkiform(getSecret("IKIFORM_SECRET_KEY") ?? "", {
        name: input.name,
        email: input.email,
        message: input.message,
        _gotcha: input._gotcha ?? "",
      }, context.request.headers.get("origin"));
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.message });
      return { sent: true };
    },
  }),
};
```

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll reply by email.</p>
) : (
  <form method="POST" action={actions.sendMessage}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10"></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 class="cf-turnstile" data-sitekey={siteKey} data-action="contact"></div>
    {result?.error && (
      <p role="alert">{isInputError(result.error) ? "Check your name, email and message." : result.error.message}</p>
    )}
    <button>Send message</button>
  </form>
)}

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

Mehr zu Astro? Lies [Spamschutz für Astro-Formulare mit Turnstile](https://ikiform.com/de/guides/spam-protection/astro.md).

**TanStack Start**

Eine POST-Funktion mit `createServerFn` liest die IP des Besuchers mit `getRequestHeader`, verifiziert das Token und leitet die Felder weiter. Die Widget-Komponente ist dieselbe wie im Next.js-Tab, unter `src/components/turnstile.tsx`. Setze `TURNSTILE_SECRET_KEY` und `IKIFORM_SECRET_KEY` als reine Server-Umgebungsvariablen und lass **Spam check** des Ikiform-Formulars auf **Off**: Dein Server hat die Menschlichkeitsprüfung bereits ausgeführt.

`src/routes/contact.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";

const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { contact: readContact(data), token: String(data.get("cf-turnstile-response") ?? "") };
  })
  .handler(async ({ data }) => {
    const human = await verifyTurnstile({
      secret: process.env.TURNSTILE_SECRET_KEY!,
      token: data.token,
      ip: getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim(),
    });
    if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again." };
    const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, data.contact, getRequestHeader("origin"));
    return result.ok ? { ok: true, message: "Thanks. We'll reply by email." } : { ok: false, message: result.message };
  });

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const send = useServerFn(sendMessage);
  const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setState(await send({ data }));
    } catch {
      setState({ ok: false, message: "Couldn't send your message. Try again." });
    } finally {
      setPending(false);
    }
  }

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

  return (
    <form method="post" onSubmit={onSubmit}>
      <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 siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={state} />
      {state?.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Mehr zu TanStack Start? Lies [Spamschutz für TanStack-Start-Formulare mit Turnstile](https://ikiform.com/de/guides/spam-protection/tanstack-start.md).

**React Router**

Die `action` der Route verifiziert das Token und leitet den Post weiter. Wird `actionData` als `reset` übergeben, rendert sich nach jeder Antwort ein frisches Widget. Die Widget-Komponente ist die aus dem Next.js-Tab. Setze `TURNSTILE_SECRET_KEY` und `IKIFORM_SECRET_KEY` als reine Server-Umgebungsvariablen und lass **Spam check** des Ikiform-Formulars auf **Off**: Dein Server hat die Menschlichkeitsprüfung bereits ausgeführt.

`app/routes/contact.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
  });
  if (!human) return data({ ok: false, message: "We couldn't confirm you're human. Try the check again." }, { status: 403 });

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, readContact(formData), request.headers.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return data({ ok: false, message: result.message }, { status: result.status });
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const navigation = useNavigation();
  const pending = navigation.state === "submitting";

  if (actionData?.ok) return <p role="status">{actionData.message}</p>;

  return (
    <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 siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={actionData} />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

Mehr zu React Router? Lies [Spamschutz für React-Router-Formulare mit Turnstile](https://ikiform.com/de/guides/spam-protection/react-router.md).

**SvelteKit**

Eine Standard-Form-Action verifiziert das Token mit `getClientAddress()` und leitet den Post weiter. Diese Version verzichtet absichtlich auf `use:enhance`: Jeder Versuch lädt die Seite neu, sodass Turnstile ein neues Widget mit neuem Token rendert. Speichere die Helfer oben als `src/lib/spam.ts`. Deklariere die Schlüssel in `src/env.ts` mit `defineEnvVars` und gib `PUBLIC_TURNSTILE_SITE_KEY` `public: true`, damit die Seite ihn aus `$app/env/public` importieren kann. Setze `TURNSTILE_SECRET_KEY` und `IKIFORM_SECRET_KEY` als reine Server-Umgebungsvariablen und lass **Spam check** des Ikiform-Formulars auf **Off**: Dein Server hat die Menschlichkeitsprüfung bereits ausgeführt.

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

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, TURNSTILE_SECRET_KEY } from "$app/env/private";
import { readContact, sendToIkiform, verifyTurnstile } from "#lib/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const data = await request.formData();
    const contact = readContact(data);
    const values = { name: contact.name, email: contact.email, message: contact.message };

    const human = await verifyTurnstile({
      secret: TURNSTILE_SECRET_KEY,
      token: String(data.get("cf-turnstile-response") ?? ""),
      ip: getClientAddress(),
    });
    if (!human) return fail(403, { values, error: "We couldn't confirm you're human. Try the check again." });

    const result = await sendToIkiform(IKIFORM_SECRET_KEY, contact, request.headers.get("origin") ?? url.origin);
    if (!result.ok) return fail(result.status, { values, error: result.message });
    return { sent: true };
  },
} satisfies Actions;
```

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

```svelte
<script lang="ts">
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

<svelte:head>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form method="POST">
    <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 minlength="10" 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 class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button>Send message</button>
  </form>
{/if}
```

Mehr zu SvelteKit? Lies [Spamschutz für SvelteKit-Formulare mit Turnstile](https://ikiform.com/de/guides/spam-protection/sveltekit.md).

**Nuxt**

Eine Server-Route verifiziert das Token und leitet den Post weiter. Speichere die Helfer oben als `server/utils/spam.ts`, und Nuxt importiert sie automatisch. Die Komponente rendert Turnstile explizit und setzt es nach einem fehlgeschlagenen Versuch zurück. Setze `TURNSTILE_SECRET_KEY` und `IKIFORM_SECRET_KEY` als reine Server-Umgebungsvariablen und lass **Spam check** des Ikiform-Formulars auf **Off**: Dein Server hat die Menschlichkeitsprüfung bereits ausgeführt.

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string | undefined>>(event);
  const config = useRuntimeConfig(event);

  const human = await verifyTurnstile({
    secret: config.turnstileSecretKey,
    token: body["cf-turnstile-response"] ?? "",
    ip: getRequestIP(event, { xForwardedFor: true }),
  });
  if (!human) throw createError({ status: 403, message: "We couldn't confirm you're human. Try the check again." });

  const result = await sendToIkiform(config.ikiformSecretKey, {
    name: body.name?.trim() ?? "",
    email: body.email?.trim() ?? "",
    message: body.message?.trim() ?? "",
    _gotcha: body._gotcha ?? "",
  }, getRequestHeader(event, "origin"));
  if (!result.ok) throw createError({ status: result.status, message: result.message });
  return { sent: true };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
    turnstileSecretKey: "",
    public: {
      turnstileSiteKey: "",
    },
  },
});
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const config = useRuntimeConfig();
const fields = reactive({ name: "", email: "", message: "", _gotcha: "" });
const box = ref<HTMLDivElement>();
const token = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
let widgetId: string | undefined;

function api() {
  return (window as Window & { turnstile?: TurnstileApi }).turnstile;
}

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    const ready = api();
    if (ready) return resolve(ready);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => api() && resolve(api()!), { once: true });
  });
}

onMounted(async () => {
  const turnstile = await loadTurnstile();
  if (!box.value) return;
  widgetId = turnstile.render(box.value, {
    sitekey: config.public.turnstileSiteKey,
    action: "contact",
    callback: (value) => (token.value = value),
    "expired-callback": () => (token.value = ""),
  });
});

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    token.value = "";
    if (widgetId) api()?.reset(widgetId);
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

Mehr zu Nuxt? Lies [Spamschutz für Nuxt-Formulare mit Turnstile](https://ikiform.com/de/guides/spam-protection/nuxt.md).

**React**

Eine Single-Page-App hat keinen Server, der ein Turnstile-Secret verwahren könnte, daher nutzt sie stattdessen die verwaltete Prüfung von Ikiform. `challenge()` aus `@ikiform/sdk` bindet einen kleinen ikiform.com-Frame ein, der unsichtbar bleibt, außer Turnstile braucht einen Klick, und gibt ein Token für `submit()` zurück. Setze **Spam check** auf **Ikiform** und füge deine Domain unter **Allowed domains** hinzu.

`src/ContactForm.tsx`:

```tsx
import { challenge, submit } from "@ikiform/sdk";
import { type FormEvent, useRef, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const check = useRef<HTMLDivElement>(null);
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setStatus("sending");
    setError("");
    try {
      const turnstileToken = await challenge(FORM_ID, { container: check.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      if (result.ok) {
        setStatus("sent");
        return;
      }
      setError(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
    } catch {
      setError("The human check didn't finish. Try again.");
    }
    setStatus("idle");
  }

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

  return (
    <form onSubmit={onSubmit}>
      <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>
      <div ref={check} />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

Dieselbe verwaltete Prüfung in einer Vue-Single-File-Component. Der Frame erscheint im leeren `div` nur, wenn ein Besucher klicken muss.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { challenge, submit } from "@ikiform/sdk";
import { ref } from "vue";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const check = ref<HTMLDivElement>();
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send(event: SubmitEvent) {
  const data = new FormData(event.target as HTMLFormElement);
  status.value = "sending";
  error.value = "";
  try {
    const turnstileToken = await challenge(FORM_ID, { container: check.value });
    const result = await submit(FORM_ID, data, { turnstileToken });
    if (result.ok) {
      status.value = "sent";
      return;
    }
    error.value = Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message;
  } catch {
    error.value = "The human check didn't finish. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" />
    <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 ref="check" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- Jede Version nutzt die Klasse `.hp` aus dem Style-Block des HTML-Tabs. Leg sie in dein globales CSS.
- Der Honeypot heißt `_gotcha`, der Standard von Ikiform. Die Server-Versionen leiten ihn unverändert weiter, sodass ein ausgefüllter trotzdem mit dem Grund `honeypot` in deinem Spam-Ordner landet.
- Turnstile fügt dem Formular ein verstecktes Eingabefeld `cf-turnstile-response` hinzu. Ein Token ist 300 Sekunden gültig und funktioniert einmal, jeder Versuch braucht also ein neues.
- Mit Testschlüsseln von Cloudflare kannst du das alles lokal ausführen. Die [Turnstile-Anleitung](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md) listet sie auf.

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

Probier die Felddefinition unten aus. Sie führt den Validator von Ikiform in deinem Browser aus, mit denselben Regeln, die der Server anwendet, bevor ein Spam-Score berechnet wird.

Formular: Contact

- **Name** (`name`, text, Pflichtfeld)
- **E-Mail** (`email`, email, Pflichtfeld)
- **Thema** (`topic`, select)
- **Nachricht** (`message`, textarea, Pflichtfeld)

Dieselben Felder wie in der [Kontaktformular-Vorlage](https://ikiform.com/forms/contact-form). Füge die Definition im Tab **Fields** des Formulars ein, und Ikiform lehnt Posts, die nicht passen, mit `422 validation_failed` ab.

## Der Honeypot

Ein Honeypot ist ein Eingabefeld, das Menschen nie sehen und Bots ausfüllen. Hat es einen Wert, kam der Post von Software. Es kostet Besucher nichts und fängt die ältesten, häufigsten Bots ab, die das HTML parsen und jedes Eingabefeld ausfüllen, das sie finden.

Wie gut du es versteckst, zählt mehr als der Trick selbst. Bei nachlässigen Honeypots gehen drei Dinge schief:

- **Screenreader lesen ihn vor.** Ein blinder Besucher hört ein zusätzliches Feld, füllt es aus und wird als Spam markiert. Pack es in `aria-hidden="true"` und gib ihm ein Label, das sagt, es leer zu lassen.
- **Die Tastatur landet darauf.** Beim Tabben durch das Formular hält der Fokus auf einem unsichtbaren Eingabefeld. `tabindex="-1"` nimmt es aus der Tab-Reihenfolge.
- **Autofill füllt ihn aus.** Browser und Passwortmanager füllen Felder anhand ihres Namens. Nennst du das Feld `email2` oder `phone`, füllen manche es für echte Menschen aus. Verwende einen Namen, den kein Autofill erkennt, etwa `_gotcha`, und füge `autocomplete="off"` hinzu.

Die [Honeypot-Anleitung](https://ikiform.com/de/guides/spam-protection/honeypot.md) behandelt Techniken zum Verstecken, wie Bots sie erkennen, und die serverseitige Prüfung für jeden Stack. Bei Ikiform erhöht ein ausgefüllter Honeypot den Score um 5, was allein die Schwelle von 5 erreicht. Du kannst das Feld pro Formular unter **Honeypot field** umbenennen.

## Timing

Menschen brauchen Sekunden, um ein Kontaktformular auszufüllen. Viele Bots brauchen Millisekunden. Eine Zeitfalle hält fest, wann das Formular gerendert wurde, und prüft beim Absenden den Abstand. Die naive Version legt einen Zeitstempel in ein verstecktes Feld:

`Zeitfalle, die naive Version`:

```ts
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;
```

Ein Bot kann jede beliebige Zahl senden, ein einfacher Zeitstempel stoppt also nur Bots, die sich keine Mühe geben. Signiere ihn auf dem Server mit einem Secret, dann kann der Bot kein langsameres Rendern vortäuschen. Die Seite zu [Spam stoppen ohne Captcha](https://ikiform.com/de/guides/spam-protection/without-captcha.md#signed-timestamp) baut die signierte Version.

Ikiform signiert Render-Zeitpunkte für dich, wenn das Formular aus dem SDK oder der [shadcn/ui-Komponente](https://ikiform.com/de/docs/shadcn.md) kommt: Sie senden `_ikiform_ts`, und ein Post weniger als 2 Sekunden nach dem Rendern erhöht den Score um 3. Ein gefälschter oder einen Tag alter Zeitstempel erhöht ihn um 3. Einfache HTML-Formulare senden keinen und werden nicht bestraft.

## Den Inhalt bewerten

Mancher Spam wird von Menschen getippt oder eingefügt und besteht daher jede Bot-Prüfung. Was ihn verrät, ist der Inhalt: Links, Verkaufsphrasen, Wegwerfadressen und derselbe Text zweimal. Bewerte jedes Signal und behandle eine hohe Summe als Spam, statt bei einem einzelnen zu blockieren. Ein Link in einer Nachricht ist normal; ein Link im Namensfeld nie.

| Signal | Ikiform addiert |
| --- | --- |
| Der Honeypot ist ausgefüllt | +5 |
| Ein Link in einem Namens- oder Betreff-Feld | +3 |
| Dieselben Felder in den letzten 24 Stunden an dieses Formular gesendet | +3 |
| Weniger als 2 Sekunden nach dem signierten Render-Zeitpunkt gesendet | +3 |
| Drei oder mehr Links über alle Felder | +2 |
| Eine von 22 bekannten Spam-Phrasen, etwa „seo services“ | +2 |
| Eine Adresse auf einer Wegwerf-E-Mail-Domain | +2 |

Ab 5 ist der Post Spam. Kein einzelnes weiches Signal erreicht das: Eine echte Nachricht mit drei Links kommt auf 2. Zwei zusammen schaffen es oft, etwa ein Link im Namen und eine Spam-Phrase. Wenn du Inhalte selbst bewertest, ist Akismet die altbewährte gehostete Option; viele Teams fangen mit ein paar regulären Ausdrücken wie den oben an.

## Eine Menschlichkeitsprüfung

Eine Menschlichkeitsprüfung verlangt vom Browser den Nachweis, dass ein Mensch ihn bedient. Moderne Varianten wie Cloudflare Turnstile laufen meist unsichtbar und zeigen nur bei Unsicherheit eine Checkbox. Das Widget gibt dem Browser ein Token; dein Server muss dieses Token zur Verifizierung an den Anbieter senden. Ein Token, das nie auf einem Server verifiziert wird, stoppt nichts, weil ein Bot das Widget überspringen und ohne Token senden kann.

Ikiform hat unter **Spam check** an jedem Formular drei Einstellungen:

| Einstellung | Was passiert | Codeänderungen |
| --- | --- | --- |
| **Off** (the default) | Keine Challenge. Honeypot, Timing und Bewertung laufen weiterhin. | Keine |
| **Ikiform** | Turnstile, von Ikiform auf ikiform.com ausgeführt, ohne Cloudflare-Konto oder Schlüssel | Keine bei HTML-Posts. `fetch`-Posts rufen zuerst `challenge()` aus `@ikiform/sdk` auf |
| **Your own Turnstile keys** (eigene Schlüssel) | Dein Widget und dein Secret Key. Ikiform verifiziert das Token und prüft seinen Hostnamen gegen deine erlaubten Domains | Rendere das Widget in deinem Formular, damit es `cf-turnstile-response` sendet |

Ein `fetch`-Post ohne Token bekommt `403 challenge_required`. Ist Cloudflare nicht erreichbar, behält Ikiform den Post und speichert ihn mit dem Grund `captcha_unavailable` als Spam, damit du ihn wiederherstellen kannst. Die [Spam-Dokumentation](https://ikiform.com/de/docs/spam.md#human-check) zeigt beide Abläufe. Wenn du Turnstile gegen Googles reCAPTCHA oder hCaptcha abwägst, sieh dir die [reCAPTCHA-Alternativen](https://ikiform.com/de/guides/spam-protection/recaptcha-alternatives.md) an.

## Rate-Limits und erlaubte Domains

Ein Rate-Limit begrenzt, wie oft ein Besucher senden kann. Es unterscheidet Spam nicht von echter Post; es stoppt Masse. Zähle pro IP und Formular, erlaube einen kleinen Burst und antworte mit `429` und einem `Retry-After`-Header. Die [Anleitung zu Rate-Limits](https://ikiform.com/de/guides/spam-protection/rate-limiting.md) baut eines für jeden Stack und erklärt, warum ein Zähler im Arbeitsspeicher auf Serverless-Hosts scheitert.

Ikiform zählt jeden Post exakt, bevor der Body gelesen wird. Jedes Formular wählt ein Preset: Standard, 5 pro Minute und Besucher, Relaxed, 20 pro Minute und Besucher, oder Strict, 2 alle 10 Minuten pro Besucher. Standard erlaubt außerdem höchstens 3 alle 10 Sekunden, und das ganze Formular nimmt 300 pro Minute an. Zu einem Kontaktformular passt Strict, weil niemand mit ehrlichen Absichten zweimal in zehn Minuten schreibt. Die Zahlen stehen unter [Rate-Limits](https://ikiform.com/de/docs/rate-limits.md).

**Allowed domains** listet die Sites auf, auf denen dein Formular läuft. Ein Post von jeder anderen Seite wird als Spam markiert oder mit `403 origin_not_allowed` abgelehnt, wenn du **Reject** wählst. Das hält Leute davon ab, ihre eigenen Formulare auf deinen Endpunkt zu richten. Ein entschlossenes Skript kann den geprüften Header fälschen, also betrachte es als eine Schicht, nicht als Schloss.

## Was mit Spam passiert

Sag einem Bot nie, dass er erwischt wurde. Ein Fehler zeigt ihm, was er ändern muss; eine normale Erfolgsseite zeigt ihm nichts. Und lösche Spam nie beim Eintreffen: Jeder Filter fängt ab und zu eine echte Nachricht ab, und eine gelöschte ist weg.

Ein als Spam eingestufter Post bekommt dieselbe Erfolgsantwort wie ein echter, ein Bot lernt also nichts. Er wird mit dem Status `spam` gespeichert, löst weder Benachrichtigungs-E-Mail noch Webhook aus, zählt nicht zu deiner Nutzung und wird nach 30 Tagen gelöscht. Jeden Montag bekommt ein Workspace, der Spam blockiert hat, eine Zusammenfassung per E-Mail mit der Gesamtzahl, den meistbetroffenen Formularen und den häufigsten Gründen.

Öffne den Statusfilter im Dashboard und wähle **Spam**, um jeden Eintrag mit Score und Gründen zu sehen. **Not spam** verschiebt ihn zurück, und dann gehen seine E-Mail und Webhooks raus. Schalte die Montags-E-Mail unter **Weekly spam summary** in den Workspace-Einstellungen aus. Spam wird 30 Tage aufbewahrt, also schau mindestens einmal im Monat nach.

> Wenn eine echte Nachricht immer wieder hängen bleibt, lies ihre Gründe. Ein Link in einem Feld namens `business_name` wird als Link in einem Namen gewertet; benenne das Feld in `business` um, dann zählt es nicht mehr als Namensfeld.

## Wähle deinen Fall

Jede dieser Seiten geht tiefer auf eine Abwehr oder eine Art von Site ein, mit Code für diesen Fall.

- [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.
- [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.
- [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.
- [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.
- [Statische Sites](https://ikiform.com/de/guides/spam-protection/static-site.md): Stopp Spam in einem Formular einer statischen Site, obwohl sie kein Geheimnis verwahren und keine Posts zählen kann, mit einem Honeypot-Partial, einem sicheren Skript und serverseitigen Prüfungen.
- [Spam in Webflow-Formularen](https://ikiform.com/de/guides/spam-protection/webflow.md): Schütze ein Webflow-Formular, das an ein Formular-Backend sendet und bei dem Webflows eigene Spam-Einstellungen nie greifen, mit einem Off-Screen-Honeypot und einer Menschlichkeitsprüfung.
- [Next.js](https://ikiform.com/de/guides/spam-protection/nextjs.md): 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.
- [Astro](https://ikiform.com/de/guides/spam-protection/astro.md): Schütze ein Astro-Formular mit einer Action vor Spam: Honeypot, Turnstile mit astro:env-Secrets geprüft, ein Limit pro IP und die eingebaute CSP von Astro.
- [TanStack Start](https://ikiform.com/de/guides/spam-protection/tanstack-start.md): 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.
- [React Router](https://ikiform.com/de/guides/spam-protection/react-router.md): 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.
- [SvelteKit](https://ikiform.com/de/guides/spam-protection/sveltekit.md): 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.
- [Nuxt](https://ikiform.com/de/guides/spam-protection/nuxt.md): Schütze ein Nuxt-Formular vor Spam: eine Nitro-Server-Route mit Turnstile, ein Limit pro IP in Nitro Storage, Secrets in runtimeConfig und eine CSP über routeRules.

## Prüfen, ob es funktioniert

Probier das an einem Testformular aus, bevor du dich darauf verlässt. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von `localhost` an. Für den Livebetrieb wähle Pro ab $6/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt. Siehe [Preise](https://ikiform.com/de/pricing).

1. Füge unter **Allowed domains** des Formulars die Domain deiner Site hinzu, plus `localhost`, während du entwickelst. Lass das Feld leer, um Posts von überall anzunehmen.
2. Füll das Formular auf deiner Seite aus und sende es ab. Du landest auf deiner Dankeseite oder der gehosteten von Ikiform.
3. Öffne **Submissions** im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
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. Sende eine Nachricht mit einem Link im Namensfeld und der Phrase `seo services` in der Nachricht. Sie wird mit den Gründen `url_in_name` und `keyword` als Spam gespeichert.
6. Stell **Spam check** auf **Ikiform** und sende das einfache HTML-Formular ab. Du siehst kurz die Prüfseite, dann deine Dankeseite.
7. Sende das Formular ein paarmal hintereinander ab. Über dem Limit antwortet das Formular mit `429`, und der Besucher sieht die Bremsseite von Ikiform.
8. Nichts angekommen? Stell den Statusfilter im Dashboard auf **Spam**. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.

## Fehlerbehebung

| Problem | Was zu tun ist |
| --- | --- |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter **Spam**, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |
| `403 challenge_required` | Die Spamprüfung des Formulars steht auf **Ikiform**, und ein `fetch`-Post hatte kein Token. Hol dir vor jedem Absenden eins mit `challenge(formId)` aus `@ikiform/sdk` oder schalte die Prüfung aus. |
| `403 origin_not_allowed`, oder jeder Post landet im Spam | Die Domain der Seite steht nicht unter **Allowed domains** des Formulars. Füge sie hinzu, plus `localhost` für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt. |
| `429 rate_limited` | Ein Besucher hat mehr gesendet, als das Rate-Limit des Formulars erlaubt. Standard erlaubt 5 pro Minute und Besucher. Warte `Retry-After` ab oder wähle Relaxed oder Custom für Formulare, die von einer gemeinsamen IP aus senden. |
| `403 captcha_failed` | Das Turnstile-Token fehlte, war abgelaufen oder wurde zweimal verwendet. Tokens leben 300 Sekunden und funktionieren einmal, also rendere nach jedem Versuch ein frisches Widget oder ruf `challenge()` erneut auf. |
| Spam kommt weiterhin als angenommen an | Öffne einen und lies, was er mit den anderen gemeinsam hat. Gegen geskripteten Spam hilft **Spam check** auf **Ikiform**; gegen getippte Pitches ein strengeres Rate-Limit und eine wöchentliche Durchsicht. |
| Echte Besucher melden, dass sie nicht senden können | Prüfe, ob der Honeypot vor Screenreadern verborgen und so benannt ist, dass Autofill ihn überspringt, und ob ein strenges Rate-Limit nicht Leute hinter einer gemeinsamen Büro-IP erwischt. |

## Fragen

### Wie stoppt man Kontaktformular-Spam am besten?

Mit Schichten, nicht mit einem Trick. Ein versteckter Honeypot, eine Mindestzeit zum Ausfüllen, eine Inhaltsbewertung und ein Rate-Limit stoppen den meisten Spam, ohne echte Besucher etwas zu kosten. Füge eine Menschlichkeitsprüfung wie Cloudflare Turnstile hinzu, wenn trotzdem Spam durchkommt, und verifiziere ihr Token auf einem Server.

### Brauche ich ein Captcha in meinem Kontaktformular?

Anfangs nicht. Der meiste Kontaktformular-Spam kommt von einfachen Bots, die ein Honeypot und ein Rate-Limit stoppen. Füge ein Captcha hinzu, wenn dein Spam-Ordner zeigt, dass Bots trotzdem durchkommen, und nimm lieber ein unsichtbares wie Turnstile als Bilderrätsel.

### Warum bekomme ich Spam von echten Menschen?

Mancher Spam wird von Menschen eingefügt, die pro Formular bezahlt werden, meist SEO- oder Entwicklungs-Pitches. Bot-Prüfungen können keinen Menschen stoppen. Die Inhaltsbewertung fängt viele an ihren Links und Phrasen ab; den Rest löschst du im Spam-Ordner.

### Sagt Ikiform dem Absender, dass seine Nachricht als Spam markiert wurde?

Nein. Spam bekommt dieselbe Erfolgsantwort wie eine echte Nachricht, Bots lernen also nichts. Er wird 30 Tage gespeichert, löst weder E-Mail noch Webhook aus, und du kannst ihn im Dashboard als kein Spam markieren.

### Brauche ich ein Cloudflare-Konto, um Turnstile mit Ikiform zu nutzen?

Nein. Setze die Spamprüfung des Formulars auf Ikiform, dann führt es Turnstile auf ikiform.com aus, ohne dass du Schlüssel verwalten musst. Nutze eigene Turnstile-Schlüssel nur, wenn du dein eigenes Widget auf deiner Seite haben willst.

### Kann ein Honeypot der Barrierefreiheit schaden?

Ja, wenn er nachlässig versteckt ist. Versteck ihn außerhalb des sichtbaren Bereichs mit aria-hidden am Wrapper, nimm ihn mit tabindex -1 aus der Tab-Reihenfolge und gib ihm einen Namen, den Browser nicht automatisch ausfüllen.

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

- [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.
- [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.
- [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.
- [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.
- [Statische Sites](https://ikiform.com/de/guides/spam-protection/static-site.md): Stopp Spam in einem Formular einer statischen Site, obwohl sie kein Geheimnis verwahren und keine Posts zählen kann, mit einem Honeypot-Partial, einem sicheren Skript und serverseitigen Prüfungen.
- [Spam in Webflow-Formularen](https://ikiform.com/de/guides/spam-protection/webflow.md): Schütze ein Webflow-Formular, das an ein Formular-Backend sendet und bei dem Webflows eigene Spam-Einstellungen nie greifen, mit einem Off-Screen-Honeypot und einer Menschlichkeitsprüfung.
- [Next.js](https://ikiform.com/de/guides/spam-protection/nextjs.md): 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.
- [Astro](https://ikiform.com/de/guides/spam-protection/astro.md): Schütze ein Astro-Formular mit einer Action vor Spam: Honeypot, Turnstile mit astro:env-Secrets geprüft, ein Limit pro IP und die eingebaute CSP von Astro.
- [TanStack Start](https://ikiform.com/de/guides/spam-protection/tanstack-start.md): 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.
- [React Router](https://ikiform.com/de/guides/spam-protection/react-router.md): 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.
- [SvelteKit](https://ikiform.com/de/guides/spam-protection/sveltekit.md): 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.
- [Nuxt](https://ikiform.com/de/guides/spam-protection/nuxt.md): Schütze ein Nuxt-Formular vor Spam: eine Nitro-Server-Route mit Turnstile, ein Limit pro IP in Nitro Storage, Secrets in runtimeConfig und eine CSP über routeRules.
- [Grenzen von Baukasten-Formularen](https://ikiform.com/de/guides/website-builder-forms.md): Wenn Formulare in Webflow, Netlify, Carrd oder Framer an ein Limit oder eine fehlende Funktion stoßen, behalte dein Formular und richte es auf ein Formular-Backend aus.
- [Feedback-Formular für eine Website oder App](https://ikiform.com/de/guides/feedback-form.md): Setze ein Feedback-Formular auf jede Seite oder in deine App: eine Bewertung, ein Kommentar, die Herkunftsseite, Spamfilter und Alerts bei niedrigen Werten.
- [Formular zur Lead-Erfassung](https://ikiform.com/de/guides/lead-capture.md): Ein Lead-Formular auf deiner eigenen Site, das dich per E-Mail mit Reply-To benachrichtigt, den Team-Chat anpingt und dein CRM füttert, damit du antwortest, solange der Lead noch da ist.
