Spam im Kontaktformular stoppen

Spam im Kontaktformular stoppen7 Min. Lesezeit

So fügst du Cloudflare Turnstile zu einem HTML-Formular hinzu

Füge Cloudflare Turnstile zu einem einfachen HTML-Formular hinzu: das Widget, serverseitiges siteverify, Testschlüssel und ein Weg ganz ohne eigene Schlüssel oder Server.

Preet SutharGründer, Ikiform

Füge das Turnstile-Skript und ein cf-turnstile-Element in dein Formular ein; das Widget legt ein Token in einem versteckten Feld cf-turnstile-response ab. Sende dann dieses Token und deinen Secret Key von einem Server an den siteverify-Endpunkt von Cloudflare und nimm den Post nur an, wenn er mit success antwortet. Ohne Server lass dein Formular-Backend es verifizieren oder nutze die verwaltete Prüfung von Ikiform, die gar keine Schlüssel braucht.

Was bei Turnstile anders ist

Turnstile hat zwei Hälften, und ein einfaches HTML-Formular hat nur für eine Platz. Das Widget läuft im Browser mit deinem öffentlichen site key und erzeugt ein Token. Die Prüfung läuft auf einem Server mit deinem secret key, der nie auf die Seite gelangen darf. Ein Formular, das das Widget zeigt, aber das Token nie verifiziert, ist Dekoration: Ein Bot sendet direkt an deinen Endpunkt und überspringt das Widget komplett.

Die eigentliche Frage für ein HTML-Formular ist also, wo das Secret liegt. Diese Seite behandelt drei Antworten: deinen eigenen kleinen Server, ein Formular-Backend, das deine Schlüssel verwahrt, und eine verwaltete Prüfung, bei der niemand außer dem Backend Schlüssel hat.

Das Leben eines Turnstile-Tokens, vom Widget bis zum UrteilIm Browser, mit dem Site KeyAuf einem Server, mit dem Secret KeyWidget läuftmeist unsichtbarTokenausgestelltverstecktes Feldcf-turnstile-responseFormular sendetToken reist mit denFeldernsiteverifySecret + Token +Besucher-IPAntwort prüfensuccess, Hostname,ActionPost annehmenablehnenabsendenalles passtetwas stimmt nichtDas Leben eines Turnstile-Tokens, vom Widget bis zum UrteilIm Browser, mit dem Site KeyAuf einem Server, mit dem Secret KeyWidget läuftmeist unsichtbarTokenausgestelltverstecktes Feldcf-turnstile-responseFormular sendetToken reist mit denFeldernsiteverifySecret + Token +Besucher-IPAntwort prüfensuccess, Hostname,ActionPost annehmenablehnenabsendenalles passtetwas stimmt nicht
  1. Das Turnstile-Widget läuft im Browser mit deinem öffentlichen Site Key. Die meisten Besucher merken nichts davon.
  2. Es stellt ein Token aus und legt es in ein verstecktes Eingabefeld cf-turnstile-response im Formular.
  3. Das Formular wird gesendet, und das Token reist mit den anderen Feldern.
  4. Dein Server sendet den Secret Key, das Token und die IP des Besuchers an den siteverify-Endpunkt von Cloudflare.
  5. Er prüft, ob die Antwort success lautet und Hostname und Action die erwarteten sind.
  6. Passt alles, wird der Post angenommen. Stimmt irgendetwas nicht, wird er abgelehnt.
Ein Token ist 300 Sekunden gültig und kann einmal verifiziert werden. Eine zweite Prüfung desselben Tokens scheitert mit timeout-or-duplicate.

Das Widget hinzufügen

Leg im Cloudflare-Dashboard unter Turnstile ein Widget an, trag die Hostnamen ein, auf denen dein Formular läuft, und kopiere den Site Key. Füge dann das Skript und ein cf-turnstile-Element ins Formular ein. Das Element muss im <form> stehen, sonst landet das versteckte Token-Feld außerhalb und wird nie mitgesendet.

contact.html
<form action="https://example.com/api/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <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>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
  • data-action="contact" beschriftet das Widget. Siteverify gibt es zurück, sodass dein Server ein Token ablehnen kann, das für ein anderes Formular erzeugt wurde.
  • Das Skript muss von Cloudflares eigener URL laden. Laut Cloudflare funktioniert das Widget mit einer über einen Proxy geleiteten oder selbst gehosteten Kopie nicht.
  • Wenn deine Site eine Content Security Policy sendet, erlaube https://challenges.cloudflare.com sowohl in script-src als auch in frame-src.
  • Kostenlos erlaubt Cloudflare bis zu 20 Widgets mit je bis zu 10 Hostnamen, also trag auch Staging-Hosts ein.

Das Token auf deinem eigenen Server verifizieren

Jeder Server kann ein Token verifizieren: ein POST an siteverify mit Secret und Token. Hier als Cloudflare Worker, an den ein statisches Formular sendet. Er prüft Token, Hostname und Action, entfernt das Token und leitet die Felder mit einem Secret Key an Ikiform weiter. Ersetze die Weiterleitung durch irgendetwas anderes, das die Nachricht speichert oder mailt.

src/index.ts
interface Env {
  TURNSTILE_SECRET_KEY: string;
  IKIFORM_SECRET_KEY: string;
}

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const form = await request.formData();

    const check = await fetch(SITEVERIFY, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: env.TURNSTILE_SECRET_KEY,
        response: String(form.get("cf-turnstile-response") ?? ""),
        remoteip: request.headers.get("CF-Connecting-IP"),
        idempotency_key: crypto.randomUUID(),
      }),
    });
    const verdict = (await check.json()) as Verdict;
    const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
    if (!verdict.success || !expected) {
      console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
      return Response.redirect(`${SITE}/contact?error=check`, 303);
    }

    form.delete("cf-turnstile-response");
    const sent = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("Origin") ?? SITE,
      },
      body: form,
    });
    return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
  },
};
  • Siteverify akzeptiert JSON- oder form-encoded Bodys und antwortet immer mit JSON. remoteip ist optional; mit idempotency_key kannst du eine Prüfung nach einem Netzwerkfehler sicher wiederholen.
  • Prüfe hostname und action, nicht nur success. Ein Token, das mit deinem Site Key auf der Seite eines anderen erzeugt wurde, sagt ebenfalls success.
  • Speichere beide Secrets mit wrangler secret put, nie in wrangler.toml.
  • Der Worker antwortet mit einer 303-Weiterleitung, das einfache Formular braucht also kein JavaScript. Richte die action des Formulars auf die Route des Workers aus.
  • Er reicht den Origin-Header der Seite weiter, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose localhost-Tests zu erkennen.

Um nach einer fehlgeschlagenen Prüfung den Fehler auf der Formularseite anzuzeigen, lies den Query-String, den der Worker setzt:

contact.html
<p id="form-error" role="alert" hidden></p>
<script>
  const reason = new URLSearchParams(location.search).get("error");
  const banner = document.getElementById("form-error");
  if (reason && banner) {
    banner.textContent =
      reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
    banner.hidden = false;
  }
</script>
Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.

Ikiform verifizieren lassen

Wenn du gar keinen Server betreiben willst, kann das Formular direkt an Ikiform senden, und Ikiform übernimmt den siteverify-Aufruf. Die Einstellung Spam check jedes Formulars entscheidet, wie.

WegSchlüssel, die du verwaltestWidget auf deiner SeiteEigener Server
Dein eigener Server oder Worker, siehe obenSite Key und Secret KeyJa, deinerJa
Ikiform mit Your own Turnstile keysSite Key und Secret Key, in Ikiform eingefügtJa, deinerNein
Ikiform mit IkiformKeineNein. Eine Prüfseite auf ikiform.com oder ein kleiner FrameNein

Bei Your own Turnstile keys fügst du Site Key und Secret Key in die Einstellungen des Formulars ein. Das Secret wird verschlüsselt gespeichert. Behalte dasselbe Widget-Markup, aber richte das Formular auf deinen Ikiform-Endpunkt aus. Ikiform verifiziert cf-turnstile-response und prüft den Hostnamen des Tokens gegen Allowed domains des Formulars. Ein Post ohne gültiges Token bekommt 403 captcha_failed, selbst mit einem geheimen API-Schlüssel.

contact.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <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>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Bei Ikiform fügst du der Seite nichts hinzu. Ein einfacher HTML-Post ohne Token bekommt eine kurze Seite Checking you're human auf ikiform.com, die Turnstile ausführt und die Felder weiterschickt. Ein fetch-Post bekommt stattdessen 403 challenge_required; ruf zuerst challenge() aus @ikiform/sdk auf, um ein Token zu bekommen. Kein Cloudflare-Konto ist beteiligt. Die Spam-Dokumentation zeigt beide Abläufe.

Ist Cloudflare nicht erreichbar, verwirft Ikiform den Post nicht: Es speichert ihn mit dem Grund captcha_unavailable als Spam, damit du ihn im Dashboard wiederherstellen kannst. Dein eigener Server sollte dieselbe Entscheidung bewusst treffen: entweder sicher fehlschlagen wie der Worker oben oder den Post zur Durchsicht behalten.

Testschlüssel

Cloudflare veröffentlicht Dummy-Schlüssel, die auf jedem Hostnamen funktionieren, auch auf localhost. Nutze sie beim Bauen und tausche sie dann gegen deine echten Schlüssel aus.

SchlüsselWertVerhalten
Site Key1x00000000000000000000AABesteht immer, sichtbares Widget
Secret Key1x0000000000000000000000000000000AASiteverify antwortet immer mit Erfolg
Secret Key2x0000000000000000000000000000000AASiteverify antwortet immer mit Fehlschlag

Test-Tokens tragen nicht deinen echten Hostnamen, daher lehnt die Hostnamen-Prüfung des Workers sie ab. Lockere diese Prüfung nur in deiner Entwicklungsumgebung, nie in der Produktion.

Prüfen, ob es funktioniert

  1. Sende das Formular normal ab. Der Post wird angenommen, und das Widget hat nichts gezeigt oder eine einzelne Checkbox.
  2. Entferne das cf-turnstile-Element und sende ab. Der Post wird abgelehnt: Der Worker leitet mit ?error=check weiter, oder Ikiform antwortet mit 403 captcha_failed.
  3. Sende ab, geh zurück und sende dieselbe Seite erneut ab. Das wiederverwendete Token scheitert, was zeigt, dass der Server es wirklich verifiziert.
  4. Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
  5. Nichts angekommen? Stell den Statusfilter im Dashboard auf Spam. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.

Stolperfallen

  • Ein Token, ein Versuch. Ruf nach einem fehlgeschlagenen, mit fetch verarbeiteten Absenden turnstile.reset() für ein frisches Token auf. Ein vollständiges Neuladen der Seite holt automatisch eins.
  • Langsame Tipper. Tokens laufen nach 300 Sekunden ab. Die Widget-Einstellung data-refresh-expired entscheidet, ob es selbst ein neues holt oder auf einen Klick wartet.
  • www und die Apex-Domain. www.example.com und example.com sind für siteverify verschiedene Hostnamen. Vergleiche mit dem, auf dem dein Formular wirklich läuft, oder erlaube beide.
  • Single-Page-Navigation. Das implizite Skript rendert Widgets einmal, wenn es lädt. Rendere bei einem clientseitigen Routenwechsel explizit mit ?render=explicit und turnstile.render().
  • Server-Posts mit Ikiforms eigenen Schlüsseln. Wenn dein Server mit einem Secret Key an Ikiform weiterleitet, überspringt Ikiform seine verwaltete Prüfung. Verifiziere das Token vorher selbst, wie es der Worker tut.

Fragen

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