Spam im Kontaktformular stoppen

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.

Preet SutharGründer, Ikiform

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.

Wie sich unsichtbare Signale bei drei verschiedenen Posts zu einem Urteil summierenEin Skript-BotEin eingefügter SEO-PitchEine echte Nachricht mit drei LinksHoneypotausgefüllt+5gesendet in 0,4 s+38: SpamLink im Namen+3"seo services"+25: Spamdrei Links+2sonst nichts2: angenommenWie sich unsichtbare Signale bei drei verschiedenen Posts zu einem Urteil summierenEin Skript-BotEin eingefügter SEO-PitchEine echte Nachricht mit drei LinksHoneypot ausgefüllt+5gesendet in 0,4 s+38: SpamLink im Namen+3"seo services"+25: Spamdrei Links+2sonst nichts2: angenommen
  1. 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.
  2. 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.
  3. Eine echte Nachricht mit drei Links kommt auf 2 und sonst nichts, also wird sie angenommen.
Die Gewichte sind die von Ikiform, mit einer Schwelle von 5. Zwei weiche Signale zusammen überschreiten sie; eines allein nie.

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.

lib/render-token.ts
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:

src/pages/contact.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.

lib/score.ts
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 };
}
src/pages/api/contact.ts
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.

SignalGewicht
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:

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

Wenn ein paar Bots trotzdem durchkommen, stell Spam check auf Ikiform. Das fügt Cloudflare Turnstile ohne Schlüssel hinzu, und die meisten Besucher sehen gar nichts. Das ist der Zeitpunkt für ein Captcha: wenn der Spam-Ordner es sagt, nicht vorher.

Prüfen, ob es funktioniert

  1. Lade das Formular und sende es innerhalb einer Sekunde ab. Mit deinem eigenen Handler wird der Post mit too_fast als Spam gespeichert.
  2. Ändere den versteckten rendered-Wert in den Entwicklertools des Browsers und sende ab. Er wird als invalid_render bewertet.
  3. 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.
  4. Sende eine Nachricht mit drei Links und sonst nichts Verdächtigem. Sie wird angenommen.
  5. 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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.