Formular-Spam stoppen ohne Google-Captcha
Jedes Kontaktformular im Netz bekommt Besuch von Maschinen. Nicht ab und zu, sondern täglich, oft im Minutentakt. Sie tragen Links in das Nachrichtenfeld ein, probieren, ob das Formular Mails an fremde Adressen verschickt, oder füllen es einfach tausendmal mit Müll, um zu sehen, was passiert. Die Standardantwort darauf heißt seit Jahren „Google reCAPTCHA". Ich baue seit einiger Zeit keins mehr ein, und die Formulare meiner Kunden bekommen trotzdem praktisch nur noch Anfragen von Menschen.
Dieser Beitrag zeigt den Aufbau, der bei mir läuft: drei Schichten, die zusammen den allermeisten automatisierten Müll abfangen, ohne dass ein Besucher Ampeln anklicken muss und ohne dass seine Daten zu einem Werbekonzern wandern. Und er zeigt die Stellen, an denen ein solcher Schutz echte Anfragen kostet, wenn man ihn unbedacht baut. Alles in reinem PHP, ohne Framework.
TL;DR - Die Kurzfassung
- reCAPTCHA lädt ein Google-Skript, bevor der Besucher etwas getan hat. Das ist datenschutzrechtlich erklärungsbedürftig und kostet vorsichtige Besucher.
- Drei Schichten reichen für die meisten normalen Kontaktformulare: ein unsichtbares Honigtopf-Feld, eine Rechenaufgabe, deren Lösung nur der Server kennt, und ein Limit pro IP-Adresse.
- Jede Prüfung passiert auf dem Server. Was im Browser geprüft wird, kann ein Bot einfach weglassen.
- Jeder Schutz hat Nebenwirkungen: Autofill füllt Honigtöpfe, Firmennetze teilen sich eine IP, Cookies können fehlen. Verworfene Absendungen müssen deshalb zählbar sein, sonst verliert man Anfragen und merkt es nie.
- Wer ein fertiges Captcha will, nimmt eines aus Europa: Friendly Captcha oder das freie Altcha, beides ohne Bilderrätsel.
Was gegen reCAPTCHA spricht
Das Google-Captcha funktioniert, keine Frage. Es funktioniert, weil Google jeden Besucher Ihres Formulars anhand verschiedener Browser-, Geräte- und Interaktionssignale bewertet, welche genau, hängt von Version und Einbindung ab. Dafür lädt die Seite ein Skript von Google, und das passiert beim Öffnen der Seite, nicht erst beim Absenden. Das gilt auch für die „unsichtbare" Version 3, bei der niemand mehr Ampeln anklickt: Sie bewertet das Verhalten still im Hintergrund, das Skript lädt trotzdem vor jeder Interaktion.
Daraus folgen zwei Probleme. Das rechtliche: Die Übermittlung an Google ist für den Betrieb des Formulars nicht zwingend erforderlich, und nach Sicht mehrerer Aufsichtsbehörden kann sie eine Einwilligung erforderlich machen. Ob das im Einzelfall so ist, hängt von Einbindung, Version und Rechtsgrundlage ab, aber ein Formular, das erst nach dem Klick auf einen Cookie-Banner funktioniert, ist in jedem Fall ein schlechteres Formular. Das praktische: Wer mit Werbeblocker, strengen Browser-Einstellungen oder aus einem Firmennetz kommt, bekommt die Bilderrätsel in voller Länge. Ich habe Kunden, die mir erzählt haben, dass sie eine Anfrage abgebrochen haben, weil sie zum dritten Mal Zebrastreifen suchen sollten. Das ist der Preis: Man verliert genau die Besucher, die man am wenigsten verlieren will, die vorsichtigen und die beruflichen.
Wer da eigentlich kommt
Bevor man baut, hilft ein Blick ins Logfile. Der Spam an Kontaktformularen kommt zu einem großen Teil von einfachen Skripten, die eine HTML-Seite laden, alle Formularfelder ausfüllen und abschicken. Sie führen kein JavaScript aus, sie lesen keine Beschriftungen, sie warten nicht. Ein kleinerer Teil steuert echte Browser fern und kann alles lesen, was auf der Seite steht. Ein sehr kleiner Teil sind Menschen in Klickfarmen, gegen die kein Captcha der Welt hilft.
Die gute Nachricht: Die erste Gruppe, die weitaus größte, lässt sich mit einfachen Mitteln fast vollständig aussperren. Gegen die zweite hilft vor allem, dass sich der Aufwand für ein einzelnes Kontaktformular nicht lohnt. Die dritte Gruppe ist für gewöhnlichen Formularspam wirtschaftlich meist uninteressant, gezielt eingesetzt wird sie aber durchaus. Man muss also nicht das perfekte Schloss bauen, sondern eines, das teurer zu knacken ist, als es dem Angreifer wert ist. Für gezielte Angriffe auf ein Registrierungsformular mit Konten oder Guthaben dahinter reicht das nicht, dazu unten mehr.
Schicht 1: Der Honigtopf, und warum er Menschen fangen kann
Das einfachste Mittel ist ein Feld, das ein Mensch nie ausfüllt, weil er es nicht sieht. Ein Bot, der alle Felder füllt, füllt auch dieses. Wer etwas hineinschreibt, ist keine Person. So die Theorie.
<!-- Im Formular: unsichtbar für Menschen, verlockend für Skripte -->
<div class="hp" aria-hidden="true">
<label for="cf-referenz">Referenz</label>
<input type="text" id="cf-referenz" name="referenz_intern" tabindex="-1" autocomplete="off">
</div>
/* Im Stylesheet, nicht per style-Attribut, das lesen manche Bots */
.hp { position: absolute; left: -9999px; }
Die Praxis hat eine Falle, die ich in der ersten Fassung dieses Beitrags selbst übersehen hatte. Der naheliegende Feldname wäre „website", weil Spam-Skripte dort gern ihre Linkliste eintragen. Aber Browser-Autofill und Passwortmanager füllen versteckte Felder mit vertrauten Namen wie website, url oder phone genauso bereitwillig aus. Die Offscreen-Position verhindert das nicht, und autocomplete="off" ignorieren etliche Browser. Ergebnis: Ein echter Interessent mit aktivem Autofill schickt ab, sieht „Danke", und seine Anfrage ist weg. Weil der Honigtopf den Fehlschlag absichtlich als Erfolg tarnt, merkt es niemand. Der Betrieb verliert Anfragen und hat keine Chance, es zu sehen.
Das ist ein Zielkonflikt, den man benennen muss: Der Feldname soll für Bots unverdächtig sein, aber nicht so vertraut, dass Autofill anspringt. Ein Name wie referenz_intern ist ein Kompromiss, keine Garantie. Deshalb gehört zum Honigtopf zwingend das Zweite: Verworfene Absendungen werden gezählt.
$honeypot = trim($_POST['referenz_intern'] ?? '');
if ($honeypot !== '') {
// Bot (oder Autofill): so tun, als wäre alles gut, nichts senden.
// Aber: protokollieren, damit Fehlgriffe sichtbar werden.
error_log(sprintf('formular verworfen: honeypot_len=%d links=%d ua="%s"',
mb_strlen($honeypot), substr_count($honeypot, 'http'),
$_SERVER['HTTP_USER_AGENT'] ?? ''));
echo json_encode(['success' => true, 'message' => 'Danke.']);
exit;
}
Der Bot bekommt weiterhin ein freundliches „Danke" und zieht weiter. Aber im Log steht jede verworfene Absendung, mit Länge des Feldes und Anzahl der Links, nicht mit dem Inhalt selbst, denn der kann wieder personenbezogen sein. Zwölf Links sind ein Bot. Eine einzelne kurze Adresse mit einem normalen Browser-Kennzeichen sieht nach Autofill aus. Wer den Inhalt zur Diagnose doch braucht, hält die Aufbewahrung kurz. Ein wöchentlicher Blick auf diese Zeilen, oder eine Zahl im täglichen Statusbericht, macht aus dem stillen Verlust einen sichtbaren. Das ist dasselbe Prinzip wie im Beitrag über Logging und Monitoring: Ein Schutz, dessen Fehlgriffe man nicht sehen kann, ist ein Schutz, dem man nicht trauen kann.
Schicht 2: Die Rechenaufgabe, deren Lösung der Server behält
Der Honigtopf fängt die dummen Skripte. Für die, die etwas mehr können, braucht es etwas, das eine Antwort verlangt. Eine kleine Rechenaufgabe reicht: „Bitte rechnen: 4 + 7". Das klingt lächerlich einfach, und genau das ist der Punkt. Für einen Menschen ist es eine Sekunde. Für ein generisches Spam-Skript, das tausende Formulare am Tag füllt, ist es eine Hürde, für die sich niemand die Mühe macht, wenn es nur um ein einzelnes Formular geht.
Ehrlich gesagt gehört dazu: Ein ferngesteuerter Browser liest „4 + 7" genauso aus der Seite wie ein Mensch und rechnet es aus. Die Rechenaufgabe hilft gegen generische Bots, nicht gegen gezielte Browserautomation. Das ist für ein Kontaktformular fast immer ausreichend, weil sich niemand für ein einzelnes Formular ein eigenes Skript schreibt. Wer wirklich Ziel eines Angriffs ist, braucht die fertigen Verfahren am Ende des Beitrags.
Entscheidend ist, wie die Aufgabe gebaut wird. Die häufigste Fehlkonstruktion: Die Lösung steht als verstecktes Feld im Formular, oder JavaScript rechnet sie im Browser aus. Beides kann ein Skript einfach mitlesen. Die Lösung darf den Server nicht verlassen. Wenn PHP die Seite ohnehin ausliefert, ist das am einfachsten direkt beim Rendern, ganz ohne JavaScript:
<?php
session_start();
$a = random_int(2, 9); $b = random_int(2, 9);
$id = bin2hex(random_bytes(8)); // Kennung dieser Aufgabe
$_SESSION['captcha'][$id] = $a + $b; // mehrere Aufgaben parallel möglich
$_SESSION['captcha'] = array_slice($_SESSION['captcha'], -5, null, true); // nicht endlos wachsen
?>
<label>Bitte rechnen: <?= $a ?> + <?= $b ?> =</label>
<input type="text" inputmode="numeric" name="captcha" required>
<input type="hidden" name="captcha_id" value="<?= $id ?>">
Zwei Details, die aus einer Demo etwas Belastbares machen. Erstens die Kennung pro Aufgabe: Wer zwei Formulare in zwei Tabs offen hat, bekommt zwei Aufgaben, und die zweite überschreibt nicht die erste. Zweitens: Das Ganze hängt an der Session, also an einem Cookie. Wer Cookies vollständig blockiert, kann das Formular nicht absenden. Das ist selten, aber der Fehlertext sollte es sagen, statt „Rechnung falsch" zu melden.
Beim Absenden prüft der Server gegen die Session, mit zwei Regeln, die den Unterschied machen:
$id = $_POST['captcha_id'] ?? '';
$eingabe = trim($_POST['captcha'] ?? '');
$loesung = $_SESSION['captcha'][$id] ?? null;
unset($_SESSION['captcha'][$id]); // Aufgabe ist verbraucht, so oder so
if ($loesung === null || $eingabe === '' || (int)$eingabe !== $loesung) {
$errors[] = 'Die Rechnung stimmt nicht. Bitte die neue Aufgabe lösen.';
}
Erstens: Ein Fehlversuch verbraucht die Aufgabe. Ein Skript kann nicht dieselbe Aufgabe mit allen Zahlen von 4 bis 18 durchprobieren, denn nach dem ersten Versuch gibt es eine neue. Zweitens: Auch der Erfolg verbraucht sie. Eine einmal gelöste Aufgabe lässt sich nicht hundertmal wiederverwenden.
Wenn die Seite als statisches HTML ausgeliefert oder gecacht wird, kann PHP die Aufgabe nicht beim Rendern einsetzen. Dann holt ein kleines Skript sie per Abruf von einer captcha.php nach. Das ist der Grund, warum man diesen Weg manchmal sieht, und er hat einen Preis: Ohne JavaScript gibt es keine Aufgabe und kein Absenden. Wer den Abruf-Weg geht, sollte im Formular einen sichtbaren Hinweis ohne JavaScript stehen haben („Bitte JavaScript aktivieren oder direkt an ... schreiben"), damit niemand vor einem stummen Formular sitzt.
Eine ergänzende Heuristik ist die Zeit: Beim Rendern ein Zeitstempel in die Session, und eine Absendung wenige Sekunden nach dem Laden ist verdächtig. Verdächtig, nicht mehr. Autofill und Passwortmanager füllen ein kurzes Formular in zwei Sekunden. Allein deshalb sollte man nie blockieren, nur mitzählen.
Schicht 3: Das Limit pro IP-Adresse
Die dritte Schicht schützt nicht vor Spam, sondern vor Volumen. Ein Bot, der die ersten beiden Hürden nimmt, soll es nicht tausendmal tun können. Und ein Mensch, der das Formular versehentlich dreimal abschickt, soll beim vierten Mal freundlich gebremst werden. Das Limit gilt pro IP-Adresse, und genau da liegen die zwei Fallen, an denen man sieht, ob jemand Infrastruktur kennt:
- Viele Menschen, eine Adresse. Ein Bürogebäude hinter einer Firewall, ein Mobilfunknetz mit Adressübersetzung beim Anbieter: Dutzende bis tausende Menschen teilen sich nach außen eine IP-Adresse. Drei Sendungen in zehn Minuten können dann ein ganzes Büro bremsen. Das Limit muss großzügig sein, und die Meldung muss sagen, was los ist.
- Ein Proxy, immer dieselbe Adresse. Läuft die Anwendung hinter einem Reverse Proxy, einem Load Balancer oder einem Dienst wie Cloudflare, steht in
REMOTE_ADDRdie Adresse des Proxys, für alle Besucher dieselbe. Dann sperrt das Limit nach drei Sendungen die ganze Welt. Die echte Adresse steht in einem Header wieX-Forwarded-For, und dem darf man nur trauen, wenn er nachweislich vom eigenen Proxy gesetzt wurde.
// Vereinfachtes Beispiel: höchstens 5 Sendungen pro Adresse in 10 Minuten.
// Bei parallelen Anfragen ohne Sperre unscharf; für ein Kontaktformular
// vertretbar, bei mehr Last flock() oder einen Zähler in Redis/DB nehmen.
$ip = $_SERVER['REMOTE_ADDR']; // hinter Proxy: echte Adresse ermitteln!
$key = hash_hmac('sha256', $ip, $RL_SECRET); // $RL_SECRET aus der Konfiguration, nicht im Code
$file = sys_get_temp_dir() . '/form_rl_' . $key;
$hits = is_file($file)
? array_filter(array_map('intval', file($file, FILE_IGNORE_NEW_LINES)),
fn($t) => $t > time() - 600)
: [];
if (count($hits) >= 5) {
http_response_code(429);
exit(json_encode(['success' => false,
'error' => 'Zu viele Anfragen von Ihrem Anschluss. Bitte in ein paar Minuten erneut versuchen oder direkt per Mail schreiben.']));
}
// ... nach erfolgreichem Versand:
$hits[] = time();
file_put_contents($file, implode("\n", $hits), LOCK_EX);
Zum Datenschutz: Der Hash macht aus der IP-Adresse ein Pseudonym, kein Geheimnis. Der Adressraum von IPv4 ist so klein, dass ein nackter SHA-256-Hash für jede mögliche Adresse in Minuten vorberechnet ist. Deshalb ein HMAC mit einem serverseitigen Geheimnis, das in der Konfiguration liegt und nicht im Code: Ohne das Geheimnis lässt sich aus dem Hash praktisch nichts Sinnvolles ableiten. Und die Dateien löscht ein nächtlicher Aufräumjob. Personenbezogene Daten liegen nur dort, wo sie gebraucht werden, und nur so lange wie nötig.
Alles auf dem Server, nichts im Browser
Das ist der Grundsatz, der alle drei Schichten trägt: Der Browser darf helfen, aber er darf nichts entscheiden. Ein Formular kann und soll im Browser prüfen, ob die Pflichtfelder gefüllt sind und die E-Mail-Adresse plausibel aussieht. Das ist Komfort für den Menschen. Aber ein Bot lädt Ihre Seite gar nicht, er schickt direkt eine Anfrage an process-form.php. Alles, was nur im Browser geprüft wird, existiert für ihn nicht.
Deshalb wiederholt der Server jede Prüfung: Pflichtfelder gefüllt, Längen im Rahmen (Name bis 80, Nachricht bis ein paar tausend Zeichen), gültiges UTF-8, E-Mail-Adresse mit filter_var. Was der Server nicht tut: Name und Nachricht auf eine Liste erlaubter Zeichen beschränken. Das erzeugt zuverlässig absurde Fehler bei Apostrophen, Umlauten, fremdsprachigen Namen und Kunden, die „O'Brien" heißen. Freitext wird nicht gefiltert, sondern dort korrekt behandelt, wo er ausgegeben wird: im HTML mit htmlspecialchars, in der CSV mit fputcsv, in der Mail als Textkörper. Und auf eine Anfrage, die kein POST ist, antwortet der Server mit einem klaren 405.
Die Mail selbst absichern
Ein Punkt, der oft übersehen wird: Das Formular verschickt am Ende eine E-Mail, und wer Eingaben ungeprüft in deren Kopfzeilen schreibt, macht aus dem Kontaktformular einen Spam-Versender. Ein Name mit einem Zeilenumbruch und einer Zeile Bcc: opfer@example.com dahinter reicht dafür. Die saubere Lösung ist nicht Filtern, sondern Struktur: Die Kopfzeilen werden fest vom Server gesetzt, mit Ihrer Absenderadresse, und die Adresse des Besuchers kommt nur als Antwortadresse hinein, nachdem filter_var sie als gültig bestätigt hat. Eine Bibliothek wie PHPMailer kapselt das und lässt keine Zeilenumbrüche in Adressfelder. Zeilenumbrüche aus dem Namen zu entfernen ist zusätzlich in Ordnung, ersetzt aber die feste Struktur nicht.
Dass die Mail mit Ihrer Absenderadresse rausgeht und nicht mit der des Besuchers, hat noch einen zweiten Grund: Sonst lehnen die Empfangsserver sie ab, weil Ihr Server nicht berechtigt ist, im Namen von gmail.com zu senden. Warum das so ist, steht im Beitrag über E-Mail-Versand aus Web-Apps.
Wenn es doch ein fertiges Captcha sein soll
Für ein Kontaktformular reichen die drei Schichten in aller Regel. Für ein Registrierungsformular, hinter dem Konten, Guthaben oder Gutscheine stehen, oder wenn ein Formular gezielt angegriffen wird, kann mehr nötig sein. Dann gibt es Alternativen, die ohne Google auskommen:
- Friendly Captcha aus Deutschland lässt den Browser des Besuchers eine kleine Rechenarbeit im Hintergrund erledigen. Der Mensch sieht nur einen Ladebalken, keine Bilder, und es werden keine Verhaltensdaten gesammelt. Der Anbieter wirbt mit Datenhaltung in der EU; welche Endpunkte und Zusagen konkret gelten, hängt vom Tarif ab und gehört vor dem Einbau geprüft. Kostenpflichtig ab einem gewissen Volumen.
- Altcha macht dasselbe als freie Software (MIT-Lizenz), die man selbst betreibt. Keine externe Abhängigkeit, kein Vertrag. Dazu gehören eine JavaScript-Komponente im Formular, die serverseitige Erzeugung der Aufgabe und die serverseitige Prüfung der Lösung, für PHP gibt es eine fertige Bibliothek.
- hCaptcha ist der bekannteste Ersatz mit Bilderrätseln, aber eben mit Bilderrätseln und mit Datenübermittlung an einen US-Anbieter. Für mich die letzte Wahl.
Das Prinzip hinter Friendly Captcha und Altcha heißt Proof of Work: Der Browser muss eine Rechenaufgabe lösen, die einen Moment dauert. Für einen Menschen unmerklich, für jemanden, der zehntausend Formulare am Tag füllen will, ein massiver Anstieg der Kosten pro Anfrage. Nicht unüberwindbar, Rechenzeit lässt sich kaufen und verteilen, aber teuer genug, dass sich ein einzelnes Formular nicht mehr lohnt. Und es ist deutlich barriereärmer als Bilderrätsel, weil niemand etwas erkennen oder anklicken muss.
Die häufigsten Fehler
- Nur im Browser prüfen. Der Bot sieht Ihren Browser-Code nie.
- Die Captcha-Lösung ins Formular schreiben. Als verstecktes Feld oder im JavaScript. Dann ist sie öffentlich.
- Verworfene Absendungen nicht zählen. Dann fängt der Honigtopf still auch Menschen, und niemand merkt es.
- Dem Bot einen Fehler zeigen. Ein Bot, der einen Fehler sieht, lernt. Ein Bot, der Erfolg sieht, geht.
- Limit auf die Proxy-Adresse. Hinter Cloudflare oder einem Load Balancer sperrt das nach drei Sendungen alle.
- Freitext auf erlaubte Zeichen filtern. Das kostet Kunden mit Apostroph im Namen. Länge prüfen, Ausgabe korrekt behandeln, fertig.
- Das Formular nach dem Einbau nie wieder testen. Ein Captcha, das nach einem Serverumzug keine Session mehr findet, blockiert alle Menschen und keinen Bot. Wer das Formular einmal im Monat selbst abschickt, merkt es.
Checkliste: Ist Ihr Formular geschützt, ohne Anfragen zu kosten?
- Lädt Ihre Kontaktseite Skripte von Google oder anderen Dritten, bevor der Besucher etwas getan hat?
- Gibt es ein Honigtopf-Feld, dessen Name Autofill nicht anlockt?
- Werden verworfene Absendungen protokolliert, und schaut jemand regelmäßig hinein?
- Liegt die Lösung der Prüfaufgabe nur auf dem Server, mit einer Kennung pro Aufgabe?
- Funktioniert das Formular ohne JavaScript, oder sagt es wenigstens, dass es JavaScript braucht?
- Gibt es ein Limit pro Adresse, und stimmt die Adresse hinter Ihrem Proxy?
- Werden alle Prüfungen auf dem Server wiederholt, unabhängig vom Browser?
- Sind die Mail-Kopfzeilen fest vom Server gesetzt, mit Ihrer Absenderadresse?
- Haben Sie das Formular in den letzten vier Wochen selbst abgeschickt?
Fazit: Höflich zu Menschen, teuer für Maschinen, und ehrlich über die Grenzen
Ein guter Formularschutz merkt der Mensch nicht. Er tippt Name, Adresse, zwei Sätze, rechnet vier plus sieben, und fertig. Die Maschine dagegen scheitert am unsichtbaren Feld, an der Aufgabe, die generische Skripte nicht berücksichtigen, oder am Limit. Das alles ohne Google, ohne Einwilligungsbanner für das Formular und ohne Bilderrätsel. Aber jeder dieser Schutzmechanismen kann unter bestimmten Umständen auch Menschen treffen, und der einzige Weg, das zu wissen, ist hinzusehen: verworfene Absendungen zählen, das Formular selbst regelmäßig abschicken, die Fehlermeldungen so schreiben, dass ein Mensch weiß, was er tun kann. Ich baue das seit Jahren so, und die Anfragen, die bei meinen Kunden ankommen, sind praktisch nur noch die von Menschen.
Ihr Kontaktformular liefert mehr Müll als Anfragen, oder Sie möchten reCAPTCHA loswerden, ohne den Spam zurückzubekommen? Schreiben Sie mir kurz, welches Formular es ist und worauf es läuft. Ich sehe mir die Verarbeitung an und sage Ihnen, welche der drei Schichten Ihnen fehlt, und wo Ihr Schutz gerade Anfragen kostet.
Weitere interessante Artikel
E-Mail-Versand aus Web-Apps: Warum Ihre Mails im Spam landen
Zuverlässiger E-Mail-Versand aus Web-Apps: SPF, DKIM, DMARC, Zustellbarkeit, Transaktionsmails.
Logging, Monitoring & Alerts: Webapps im Betrieb sichtbar machen
Strukturiertes Logging, Monitoring und sinnvolle Alerts: wie Ihre Webapp im Betrieb sichtbar wird und Sie Fehler sehen, bevor Nutzer …
Login-Sicherheit: 2FA und Passkeys für Geschäftsanwendungen
Login-Sicherheit für Geschäftsanwendungen: Argon2id, TOTP mit Replay-Schutz, Passkeys und eine Wiederherstellung, die nicht schwächer ist.