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.
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.
- 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.
- 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.
- 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.
Deine Optionen
| Option | Spamprüfungen | Nachteile |
|---|---|---|
| Netlify Forms | Akismet bei jedem Post, plus ein Feld netlify-honeypot und reCAPTCHA v2, wenn du sie hinzufügst | Nur auf Netlify. Ein Hostwechsel bedeutet, das Formular neu zu bauen. Siehe Netlify-Forms-Alternativen |
| Eine Funktion bei deinem Host | Was immer du schreibst: Honeypot, Token-Prüfungen, ein Rate-Limit mit Speicher | Du schreibst und pflegst sie und brauchst dahinter einen E-Mail- oder Speicherdienst |
| Ein Formular-Backend wie Ikiform | Honeypot, Bewertung, Rate-Limits, erlaubte Domains und eine Menschlichkeitsprüfung, ohne Code | Ein 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><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.
_redirectschickt die Leute auf deine Dankeseite. Ikiform folgt ihm nur bei einerhttps:-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.
<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 dassubmit-Event erneut auszulösen, der Fallback kann also nicht in eine Schleife geraten.- Ein
429zeigt ausRetry-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.
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.ipist die IP des Besuchers, die siteverify alsremoteipnutzen kann.- Setze
TURNSTILE_SECRET_KEYundRESEND_API_KEYin 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.
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
- Schalte JavaScript aus und sende ab. Das Formular sendet trotzdem, und du landest auf deiner Dankeseite.
- Schalte JavaScript ein und sende ab. Das Formular wird durch die Dankeszeile ersetzt.
- 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. - 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.
- 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, pluslocalhostfü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-hiddenundtabindexhat. - 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
Du kannst das Widget anzeigen, aber sein Token muss auf einem Server mit einem Secret Key verifiziert werden. Nutze eine Funktion bei deinem Host, ein Formular-Backend, das deine Schlüssel verifiziert, oder die verwaltete Prüfung von Ikiform, die keine Schlüssel braucht.
Er stoppt die häufigsten Bots, aber nur, wenn etwas auf einem Server ihn liest. Kombiniere ihn mit einem Rate-Limit und einer Inhaltsbewertung, die ebenfalls einen Server oder ein Formular-Backend brauchen.
Das Formular sendet an eine Seite auf einem Host, der nur Dateien ausliefert. Richte die action des Formulars stattdessen auf eine Funktion oder einen Formular-Backend-Endpunkt aus.
Das sollte es. Sende zuerst mit einem einfachen HTML-Formular und füge ein Skript hinzu, das es verbessert und auf einen normalen Post zurückfällt, wenn etwas schiefgeht.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.