Spam im Kontaktformular stoppen6 Min. Lesezeit
So stoppst du Formular-Spam ohne Captcha
Stopp den meisten Formular-Spam ganz ohne Captcha: Honeypot, signierte Render-Zeit, Inhaltsbewertung und Rate-Limit, zusammen statt einzeln eingesetzt.
Kombiniere vier unsichtbare Prüfungen und addiere, was sie finden: einen Honeypot, einen signierten Zeitstempel, der zeigt, wie lange das Formular offen war, eine Bewertung für Links und Spam-Phrasen und ein Rate-Limit pro Besucher. Behandle einen Post erst als Spam, wenn die Summe eine Schwelle überschreitet, damit kein einzelnes weiches Signal einen echten Menschen blockiert. Behalte, was du abfängst, und füge erst später ein Captcha hinzu, wenn der Spam-Ordner zeigt, dass trotzdem Bots durchkommen.
Was ohne Captcha anders ist
Ein Captcha gibt dir ein starkes Ja oder Nein. Ohne hast du stattdessen mehrere schwache Signale, und jedes allein lässt sich leicht umgehen oder versehentlich auslösen. Ein schneller Post kann ein Bot sein oder jemand, der eine vorbereitete Nachricht eingefügt hat. Ein Link kann Spam sein oder ein Portfolio. Der Trick ist zu bewerten: Gib jedem Signal ein Gewicht, addiere sie und nenne es erst ab einer Schwelle Spam. Das eine harte Signal, ein ausgefüllter Honeypot, bekommt ein Gewicht, das allein über die Grenze reicht.
- Ein Skript-Bot füllt den Honeypot aus, was 5 hinzufügt, und sendet in unter einer Sekunde, was 3 hinzufügt. Seine Summe von 8 ist Spam.
- Ein eingefügter SEO-Pitch hat einen Link im Namensfeld, 3, und eine bekannte Spam-Phrase, 2. Seine Summe von 5 erreicht die Schwelle von 5.
- Eine echte Nachricht mit drei Links kommt auf 2 und sonst nichts, also wird sie angenommen.
Eine signierte Render-Zeit
Menschen brauchen mehrere Sekunden, um ein Formular auszufüllen. Eine Zeitfalle misst diesen Abstand. Die schwache Version legt Date.now() in ein verstecktes Feld, das jeder Bot umschreiben kann. Die starke Version signiert die Render-Zeit auf dem Server mit einem Secret, sodass ein Bot nicht behaupten kann, die Seite sei früher geöffnet worden. Diese Datei läuft überall mit Web Crypto: Node 20 und neuer, Deno, Bun, Workers und jede Serverless-Runtime.
const encoder = new TextEncoder();
async function key(secret: string) {
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
"sign",
"verify",
]);
}
function toHex(bytes: ArrayBuffer) {
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function fromHex(hex: string) {
return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}
export async function signRender(secret: string, now = Date.now()) {
const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
return `${now}.${toHex(signature)}`;
}
export type RenderCheck = "ok" | "too_fast" | "invalid";
export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
const [time = "", signature = ""] = token.split(".");
if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
const age = now - Number(time);
if (!valid || age < 0 || age > 86_400_000) return "invalid";
return age < minSeconds * 1000 ? "too_fast" : "ok";
}Signiere beim Rendern der Seite, die Seite muss also pro Request gerendert werden und darf nicht als statisches HTML gecacht sein. In Astro:
---
import { signRender } from "../lib/render-token";
export const prerender = false;
const rendered = await signRender(import.meta.env.RENDER_SECRET);
---
<form method="POST" action="/api/contact">
<input type="hidden" name="rendered" value={rendered} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="name">Name</label>
<input id="name" name="name" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button>Send</button>
</form>- Drei Sekunden sind ein sicheres Minimum für ein Kontaktformular. Jemand, der eine vorbereitete Nachricht einfügt, kann trotzdem zwei brauchen.
- Lehne Tokens ab, die älter als einen Tag sind. Sonst lädt ein Bot die Seite einmal und verwendet dasselbe Token wochenlang.
- Behandle ein fehlendes oder gefälschtes Token als Signal, nicht als Beweis. Eine gecachte Seite oder ein alter Tab kann so eins erzeugen.
Den Post bewerten
Fasse die Signale in einer Funktion zusammen. Link- und Phrasenprüfungen fangen ab, was Honeypot und Timing nicht können: Nachrichten, die Menschen getippt oder eingefügt haben. Halte die Phrasenliste kurz und spezifisch; allgemeine Wörter wie free markieren echte Kunden.
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;
type Reason = keyof typeof WEIGHTS;
export function scoreContact(input: {
name: string;
message: string;
trap: string;
render: "ok" | "too_fast" | "invalid";
}) {
const reasons: Reason[] = [];
if (input.trap.trim()) reasons.push("honeypot");
if (input.render === "too_fast") reasons.push("too_fast");
if (input.render === "invalid") reasons.push("invalid_render");
if (input.name.match(LINK)) reasons.push("link_in_name");
if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
return { score, reasons, spam: score >= THRESHOLD };
}import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";
export const POST: APIRoute = async ({ request, redirect }) => {
const form = await request.formData();
const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
const verdict = scoreContact({
name: String(form.get("name") ?? ""),
message: String(form.get("message") ?? ""),
trap: String(form.get("_gotcha") ?? ""),
render,
});
await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
return redirect("/thanks", 303);
};Der Handler speichert alles mit einem Status, statt Spam zu verwerfen, und beantwortet beides mit derselben Weiterleitung. Du siehst den Spam später durch, und ein Bot erfährt nie, welches Signal ihn erwischt hat. Schalte ein Rate-Limit pro Besucher vor diese Route, um Fluten zu stoppen, bevor irgendetwas davon läuft.
Ikiform führt das ohne Captcha aus
Jedes Ikiform-Formular führt Honeypot, Timing, Inhaltsbewertung und Rate-Limits ohne Einrichtung aus, und sein Spam check steht anfangs auf Off. Der Honeypot heißt _gotcha. Die Inhaltsbewertung prüft Links, 22 bekannte Spam-Phrasen, Wegwerf-E-Mail-Domains und identische Wiederholungen innerhalb von 24 Stunden. Das Rate-Limit beginnt bei 5 pro Minute und Besucher. Ab 5 ist ein Post Spam.
| Signal | Gewicht |
|---|---|
| Honeypot ausgefüllt | +5 |
| Weniger als 2 Sekunden nach dem signierten Rendern gesendet | +3 |
| Render-Zeit gefälscht oder älter als 24 Stunden | +3 |
| Ein Link in einem Namens- oder Betreff-Feld | +3 |
| Identische Felder innerhalb von 24 Stunden | +3 |
| Drei oder mehr Links | +2 |
| Eine bekannte Spam-Phrase | +2 |
| Eine Wegwerf-E-Mail-Domain | +2 |
Die signierte Render-Zeit von Ikiform kommt aus dem öffentlichen Schema des Formulars. Die shadcn/ui-Komponente sendet sie für dich. Ein Formular, das du selbst renderst, kann sie mit getSchema() aus @ikiform/sdk abrufen und als _ikiform_ts zurücksenden:
import { getSchema, submit } from "@ikiform/sdk";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
if (rendered) data.set("_ikiform_ts", rendered);
const result = await submit(FORM_ID, data);
form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});Einfache HTML-Formulare senden keine Render-Zeit und werden dafür nicht bestraft, eine fehlende kostet also nichts. 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. Der Schema-Endpunkt und die Spam-Dokumentation enthalten die Details.
Prüfen, ob es funktioniert
- Lade das Formular und sende es innerhalb einer Sekunde ab. Mit deinem eigenen Handler wird der Post mit
too_fastals Spam gespeichert. - Ändere den versteckten
rendered-Wert in den Entwicklertools des Browsers und sende ab. Er wird alsinvalid_renderbewertet. - 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 drei Links und sonst nichts Verdächtigem. Sie wird angenommen.
- Nichts angekommen? Stell den Statusfilter im Dashboard auf Spam. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.
Stolperfallen
- Statische Seiten. Eine Seite, die einmal beim Deployment gebaut wird, hat für jeden Besucher dieselbe Render-Zeit. Signiere pro Request oder hol per JavaScript ein frisches Token, wenn das Formular erscheint.
- Passwortmanager und Einfügen. Manche Leute füllen ein Formular per Autofill in einer Sekunde aus. Lass Timing ein weiches Signal bleiben, damit es nie allein blockiert.
- Phrasenlisten veralten. Spam-Phrasen ändern sich. Sieh alle paar Wochen durch, was durchkommt, und füge die konkrete Phrase hinzu, nie ein gängiges Wort.
- Wiederholungen sind normal. Leute senden erneut, wenn sie keine Antwort bekommen. Gewichte eine identische Wiederholung, aber blockiere sie nicht komplett.
Fragen
Den Großteil. Ein Honeypot, eine signierte Render-Zeit, eine Inhaltsbewertung und ein Rate-Limit stoppen das meiste, was eine kleine Site abbekommt, ohne echte Besucher etwas zu kosten. Bots, die einen vollständigen Browser ausführen und warten, kommen eventuell trotzdem durch, und dann hilft eine unsichtbare Prüfung wie Turnstile.
Etwa drei Sekunden bei einem Kontaktformular. Kürzer verpasst schnelle Bots; länger erwischt allmählich Leute, die eine vorbereitete Nachricht einfügen oder Autofill nutzen. Nutze sie als ein Signal unter mehreren, nicht als harte Sperre.
Einen einfachen Zeitstempel in einem versteckten Feld kann jeder Bot so umschreiben, dass er älter aussieht. Signierst du ihn auf dem Server mit einem Secret, kann nur dein Server eine gültige Render-Zeit erzeugen.
Nein. Honeypot, signiertes Timing, Inhaltsbewertung und Rate-Limits laufen bei jedem Formular mit ausgeschalteter Spamprüfung. Die verwaltete Turnstile-Prüfung ist ein Extra, das du pro Formular einschaltest, wenn trotzdem Spam durchkommt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.