Anleitungen16 Min. Lesezeit
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.
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.
- Ein Post trifft am Formular-Endpunkt ein.
- Das Rate-Limit zählt zuerst die Posts pro Besucher. Eine Flut wird mit 429 abgelehnt, bevor der Body gelesen wird.
- Erlaubte Domains vergleichen die Seite, von der der Post kam, mit den Domains, die du eingetragen hast.
- Ist die Menschlichkeitsprüfung an, muss der Post ein gültiges Turnstile-Token enthalten.
- Der Honeypot erhöht den Score um 5, wenn er ausgefüllt ist.
- Timing, Links, bekannte Spam-Phrasen, Wegwerfadressen und Wiederholungen erhöhen ihn um kleinere Beträge.
- Ab 5 wird der Post als Spam gespeichert. Darunter wird er angenommen, und deine E-Mail und Webhooks gehen raus.
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 | 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 | 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 | Die meisten automatisierten Browser | Bezahlte menschliche Spammer | Meist unsichtbar. Manchmal ein Klick, und es braucht ein Skript |
| reCAPTCHA oder hCaptcha | 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 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.
- Zufälliger Text in jedem Feld kommt von einem Skript, das alle Eingabefelder ausfüllt. Ein Honeypot fängt es ab.
- Dutzende Posts pro Minute aus einer Quelle kommen von einem Skript in einer Schleife. Ein Rate-Limit stoppt es.
- 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.
- Höfliche Pitches mit Links kommen von Menschen, die pro Formular bezahlt werden. Nur Inhaltsbewertung und deine eigene Durchsicht fangen sie ab.
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.
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." };
}
}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.
<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.
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.
"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 };
}"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>
);
}"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.
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.
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 };
},
}),
};---
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.
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.
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.
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.
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.
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.
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;<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.
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.
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 };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
turnstileSecretKey: "",
public: {
turnstileSiteKey: "",
},
},
});<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.
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.
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>
);
}Dieselbe verwaltete Prüfung in einer Vue-Single-File-Component. Der Frame erscheint im leeren div nur, wenn ein Besucher klicken muss.
<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
.hpaus 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 Grundhoneypotin deinem Spam-Ordner landet. - Turnstile fügt dem Formular ein verstecktes Eingabefeld
cf-turnstile-responsehinzu. 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 listet sie auf.
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.
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
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
email2oderphone, füllen manche es für echte Menschen aus. Verwende einen Namen, den kein Autofill erkennt, etwa_gotcha, und fügeautocomplete="off"hinzu.
Die Honeypot-Anleitung 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:
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 baut die signierte Version.
Ikiform signiert Render-Zeitpunkte für dich, wenn das Formular aus dem SDK oder der shadcn/ui-Komponente 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 zeigt beide Abläufe. Wenn du Turnstile gegen Googles reCAPTCHA oder hCaptcha abwägst, sieh dir die reCAPTCHA-Alternativen 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 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.
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.
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-FeldFü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 TurnstileFü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 CaptchaStopp den meisten Formular-Spam ganz ohne Captcha: Honeypot, signierte Render-Zeit, Inhaltsbewertung und Rate-Limit, zusammen statt einzeln eingesetzt.
- Alternativen zu reCAPTCHAVergleiche 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-LimitsBegrenze 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 SitesStopp 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-FormularenSchü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.jsSchü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.
- AstroSchü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 StartSchü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 RouterSchü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.
- SvelteKitSchü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.
- NuxtSchü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.
- 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. - Füll das Formular auf deiner Seite aus und sende es ab. Du landest auf deiner Dankeseite oder der gehosteten von Ikiform.
- Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
- 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. - Sende eine Nachricht mit einem Link im Namensfeld und der Phrase
seo servicesin der Nachricht. Sie wird mit den Gründenurl_in_nameundkeywordals Spam gespeichert. - Stell Spam check auf Ikiform und sende das einfache HTML-Formular ab. Du siehst kurz die Prüfseite, dann deine Dankeseite.
- Sende das Formular ein paarmal hintereinander ab. Über dem Limit antwortet das Formular mit
429, und der Besucher sieht die Bremsseite von Ikiform. - 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
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.
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.
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.
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.
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.
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.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.