Spam im Kontaktformular stoppen

Spam im Kontaktformular stoppen6 Min. Lesezeit

So stoppst du Spam über das Kontaktformular einer statischen Site

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.

Preet SutharGründer, Ikiform

Eine statische Site hat keinen Server, nichts auf der Seite kann also einen Secret Key verwahren, ein Captcha-Token verifizieren oder Posts pro Besucher zählen. Diese Prüfungen müssen anderswo laufen: in einer Funktion bei deinem Host oder in einem Formular-Backend, an das dein Formular sendet. Setze in jedem Fall einen barrierefreien Honeypot ins Formular und sorg dafür, dass das Formular ohne JavaScript funktioniert.

Was bei einer statischen Site anders ist

Bei einer statischen Site ist jedes Byte, das du deployst, öffentlich. Alles in deinem HTML oder JavaScript kann ein Bot lesen und überspringen, clientseitige Spamprüfungen sind also Empfehlung, keine Durchsetzung. Die drei wichtigsten Prüfungen brauchen alle einen Server:

  • Ein Captcha-Token verifizieren braucht einen Secret Key, der nicht in einer öffentlichen Datei liegen kann.
  • Posts pro Besucher zählen braucht einen Speicher, der zwischen Requests erhalten bleibt.
  • Einen Honeypot lesen braucht Code, der nach dem Eintreffen des Posts läuft, außerhalb der Reichweite des Bots.

Statische Hosts nehmen außerdem keine Posts an deine Seiten an. Vercel und GitHub Pages beantworten einen Formular-POST an eine statische Seite mit 405 Method Not Allowed, das Formular braucht also einen Endpunkt, bevor Spam überhaupt ein Thema ist.

Drei Arten, ein Formular einer statischen Site anzubinden, und wo jeweils die Spamprüfungen laufenDie Seite alleinMit einer Host-FunktionMit einem Formular-Backendstatische SeiteFormular sendet an sichselbstHostkein Code läuft405 oder verlorenstatische SeiteFunktionSecret, Honeypot,Token-Prüfungdeine E-Mail oderdein Speicherstatische SeiteFormular-BackendHoneypot, Score, Limit,TurnstilePostfach undWebhooksDrei Arten, ein Formular einer statischen Site anzubinden, und wo jeweils die Spamprüfungen laufenDie Seite alleinMit einer Host-FunktionMit einem Formular-Backendstatische SeiteFormular sendet an sich selbstHostkein Code läuft405 oder verlorenstatische SeiteFunktionSecret, Honeypot, Token-Prüfungdeine E-Mail oder deinSpeicherstatische SeiteFormular-BackendHoneypot, Score, Limit,TurnstilePostfach und Webhooks
  1. Eine statische Seite, die an sich selbst sendet, erreicht einen Host, der keinen Code ausführt, also scheitert der Post mit 405 oder geht ins Leere.
  2. Mit einer Host-Funktion sendet die Seite an eine Funktion, die das Secret hält, den Honeypot liest und das Token verifiziert und die Nachricht dann weiterschickt.
  3. Mit einem Formular-Backend sendet die Seite direkt an das Backend, das Honeypot, Bewertung, Rate-Limit und Menschlichkeitsprüfung ausführt, bevor es dir eine E-Mail schickt.
In beiden funktionierenden Zeilen laufen die Prüfungen, nachdem der Post den Browser verlassen hat. Nur dort kann ein Bot sie nicht überspringen.

Deine Optionen

OptionSpamprüfungenNachteile
Netlify FormsAkismet bei jedem Post, plus ein Feld netlify-honeypot und reCAPTCHA v2, wenn du sie hinzufügstNur auf Netlify. Ein Hostwechsel bedeutet, das Formular neu zu bauen. Siehe Netlify-Forms-Alternativen
Eine Funktion bei deinem HostWas immer du schreibst: Honeypot, Token-Prüfungen, ein Rate-Limit mit SpeicherDu schreibst und pflegst sie und brauchst dahinter einen E-Mail- oder Speicherdienst
Ein Formular-Backend wie IkiformHoneypot, Bewertung, Rate-Limits, erlaubte Domains und eine Menschlichkeitsprüfung, ohne CodeEin weiterer Dienst, abgerechnet pro Einsendung

Das Formular als wiederverwendbares Partial

Egal welchen Endpunkt du wählst, schreib das Formular einmal als Partial oder Include, damit jede Seite denselben Honeypot nutzt. Die Version unten sendet an Ikiform. Um stattdessen eine Funktion zu nutzen, ändere action auf den Pfad der Funktion und lass _redirect weg.

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
assets/contact.css
<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
  • Der Honeypot liegt außerhalb des sichtbaren Bereichs, außerhalb der Tab-Reihenfolge und vor Screenreadern verborgen. Die Honeypot-Anleitung erklärt jedes Attribut.
  • _redirect schickt die Leute auf deine Dankeseite. Ikiform folgt ihm nur bei einer https:-Adresse auf einer der Allowed domains des Formulars.
  • Die Anleitungen zu Hugo, Jekyll und Eleventy behandeln Einrichtung, Shortcodes und GitHub Pages.

Verbessern, ohne es kaputtzumachen

Ein Skript kann Besucher auf der Seite halten, darf aber nie der einzige Weg sein, wie das Formular funktioniert. Dieses sendet mit fetch und fällt in zwei Fällen auf einen normalen Browser-Post zurück: wenn der Netzwerkaufruf scheitert und wenn die Menschlichkeitsprüfung von Ikiform mit challenge_required nach einem Token fragt. Ein normaler Post bekommt stattdessen die Prüfseite von Ikiform, du kannst die Prüfung also einschalten, ohne das Skript zu ändern.

contact-form.html
<script>
  document.querySelectorAll("form.contact").forEach((form) => {
    const status = form.querySelector("[role=status]");
    const button = form.querySelector("button");

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      try {
        const response = await fetch(form.action, {
          method: "POST",
          headers: { Accept: "application/json" },
          body: new FormData(form),
        });
        const result = await response.json();
        if (result.ok) {
          form.replaceChildren("Thanks. We'll reply by email.");
          return;
        }
        if (result.error.code === "challenge_required") {
          form.submit();
          return;
        }
        const wait = response.headers.get("Retry-After");
        if (response.status === 429 && wait) {
          status.textContent = `Too many messages. Try again in ${wait} seconds.`;
        } else {
          status.textContent = result.error.message;
        }
      } catch {
        form.submit();
        return;
      }
      button.disabled = false;
    });
  });
</script>
  • form.submit() sendet das Formular, ohne das submit-Event erneut auszulösen, der Fallback kann also nicht in eine Schleife geraten.
  • Ein 429 zeigt aus Retry-After, wie lange zu warten ist, was Ikiform für Browser-Code freigibt.
  • Die Erfolgszeile ersetzt das Formular, sodass es niemand versehentlich zweimal sendet.

Wenn du eine Host-Funktion schreibst

Netlify, Cloudflare Pages und Vercel führen alle kleine Funktionen neben statischen Dateien aus. Eine Funktion ist dein eigenes Backend, ihr gehören also die Spamprüfungen. Diese Netlify Function liest den Honeypot, verwirft mit Links vollgestopfte Nachrichten, verifiziert ein Turnstile-Token und schickt dir über Resend eine E-Mail. Jeder abgefangene Post bekommt dieselbe Danke-Weiterleitung wie ein echter.

netlify/functions/contact.mts
import type { Config, Context } from "@netlify/functions";

const LINK = /https?:\/\/|www\./gi;

export default async (req: Request, context: Context) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const form = await req.formData();
  const email = String(form.get("email") ?? "");
  const message = String(form.get("message") ?? "");
  const thanks = Response.redirect(new URL("/thanks/", req.url), 303);

  if (String(form.get("_gotcha") ?? "").trim()) return thanks;
  if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;

  const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: new URLSearchParams({
      secret: process.env.TURNSTILE_SECRET_KEY ?? "",
      response: String(form.get("cf-turnstile-response") ?? ""),
      remoteip: context.ip,
    }),
  });
  const verdict = (await check.json()) as { success: boolean };
  if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);

  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: "Contact form <form@example.com>",
      to: "you@example.com",
      reply_to: email,
      subject: "New message from the website",
      text: message,
    }),
  });
  return thanks;
};

export const config: Config = { path: "/api/contact" };
  • context.ip ist die IP des Besuchers, die siteverify als remoteip nutzen kann.
  • Setze TURNSTILE_SECRET_KEY und RESEND_API_KEY in den Umgebungsvariablen der Site, nie im Repository.
  • Sie verwirft abgefangene Posts, statt sie zu speichern, eine versehentlich abgefangene echte Nachricht ist also weg. Logge sie zumindest.
  • Sie hat kein Rate-Limit, da eine Funktion zwischen Aufrufen kein Gedächtnis hat. Die Anleitung zu Rate-Limits fügt eines mit Redis hinzu.

Was Ikiform für ein statisches Formular tut

Wenn das Formular direkt an Ikiform sendet, passiert die serverseitige Arbeit dort. Der Honeypot heißt _gotcha, Inhaltsbewertung und Rate-Limits pro Besucher laufen bei jedem Post, und Allowed domains markiert Posts von anderen Sites als Spam. Stell Spam check auf Ikiform für Cloudflare Turnstile ohne Schlüssel: Der einfache HTML-Post führt es auf einer Prüfseite aus, und das Skript oben fällt auf diese Seite zurück. 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.

Trag jeden Host, auf dem das Formular veröffentlicht ist, unter Allowed domains ein: deine Domain plus, während du testest, die eigene Adresse des Hosts, etwa username.github.io. Deploy Previews von Netlify und Preview-URLs von Vercel sind keine Subdomains der Adresse deiner Hauptsite; füge den genauen Preview-Host hinzu, während du eine testest.

Prüfen, ob es funktioniert

  1. Schalte JavaScript aus und sende ab. Das Formular sendet trotzdem, und du landest auf deiner Dankeseite.
  2. Schalte JavaScript ein und sende ab. Das Formular wird durch die Dankeszeile ersetzt.
  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. Setze Spam check auf Ikiform und sende mit eingeschaltetem Skript ab. Die Seite fällt auf einen normalen Post zurück und zeigt die Prüfseite.
  5. Nichts angekommen? Stell den Statusfilter im Dashboard auf Spam. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.

Stolperfallen

  • 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.
  • Minifiziertes HTML. Manche Build-Schritte entfernen Attribute, die sie für leer oder ungenutzt halten. Prüfe, ob die gebaute Seite am Honeypot noch aria-hidden und tabindex hat.
  • Gecachte Dankeseiten. Wenn die Dankeseite für Spam und echte Posts dieselbe URL hat, was sie sollte, stell nie etwas darauf, das davon abhängt, dass der Post echt ist.
  • Schlüssel im Repository. Ein Secret, das ins Repository einer öffentlichen statischen Site committet wurde, ist öffentlich. Rotiere es, falls es je dort war.

Fragen

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