# 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.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile

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.

Diagramm: Das Leben eines Turnstile-Tokens, vom Widget bis zum Urteil

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

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

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

```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](https://ikiform.com/de/docs/rate-limits.md) 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.

| Weg | Schlüssel, die du verwaltest | Widget auf deiner Seite | Eigener Server |
| --- | --- | --- | --- |
| Dein eigener Server oder Worker, siehe oben | Site Key und Secret Key | Ja, deiner | Ja |
| Ikiform mit **Your own Turnstile keys** | Site Key und Secret Key, in Ikiform eingefügt | Ja, deiner | Nein |
| Ikiform mit **Ikiform** | Keine | Nein. Eine Prüfseite auf ikiform.com oder ein kleiner Frame | Nein |

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

```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](https://ikiform.com/de/docs/spam.md#human-check) 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üssel | Wert | Verhalten |
| --- | --- | --- |
| Site Key | `1x00000000000000000000AA` | Besteht immer, sichtbares Widget |
| Secret Key | `1x0000000000000000000000000000000AA` | Siteverify antwortet immer mit Erfolg |
| Secret Key | `2x0000000000000000000000000000000AA` | Siteverify 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

### Kann ich Cloudflare Turnstile ohne Server nutzen?

Das Widget läuft überall, aber das Token muss auf einem Server mit deinem Secret Key verifiziert werden. Ohne Server sende das Formular an ein Formular-Backend, das es für dich verifiziert, oder nutze die verwaltete Prüfung von Ikiform, die keine Schlüssel braucht.

### Ist Cloudflare Turnstile kostenlos?

Cloudflare bietet es kostenlos für bis zu 20 Widgets mit je bis zu 10 Hostnamen an, und die Tarifseite nennt unbegrenzte Challenges. Prüfe die aktuelle Tarifseite von Cloudflare, bevor du dich darauf verlässt.

### Warum sagt siteverify timeout-or-duplicate?

Das Token ist älter als 300 Sekunden oder wurde bereits einmal verifiziert. Rendere nach jedem Absendeversuch ein frisches Widget oder setze es zurück.

### Brauche ich ein Cloudflare-Konto, um Turnstile mit Ikiform zu nutzen?

Nein. Setze die Spamprüfung des Formulars auf Ikiform, dann führt Ikiform Turnstile auf ikiform.com aus. Ein Konto brauchst du nur, um eigene Schlüssel und ein eigenes Widget zu nutzen.

## Weitere Anleitungen

- [Spam im Kontaktformular stoppen](https://ikiform.com/de/guides/spam-protection.md): Stopp Kontaktformular-Spam mit Schichten, die echte Menschen nichts kosten: Honeypot, Timing, Inhaltsbewertung, Rate-Limits und eine Menschlichkeitsprüfung nur bei Bedarf.
- [Alternativen zu reCAPTCHA](https://ikiform.com/de/guides/spam-protection/recaptcha-alternatives.md): Vergleiche reCAPTCHA-Alternativen für Formulare, von Turnstile und hCaptcha bis Proof-of-Work und ganz ohne Captcha, und wechsle mit einer kleinen, sicheren Änderung.
- [Statische Sites](https://ikiform.com/de/guides/spam-protection/static-site.md): 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.
- [Ohne Captcha](https://ikiform.com/de/guides/spam-protection/without-captcha.md): Stopp den meisten Formular-Spam ganz ohne Captcha: Honeypot, signierte Render-Zeit, Inhaltsbewertung und Rate-Limit, zusammen statt einzeln eingesetzt.
- [Honeypot-Feld](https://ikiform.com/de/guides/spam-protection/honeypot.md): Füge jedem Formular einen Honeypot hinzu, der Bots abfängt, ohne Screenreader, Tastatur oder Autofill zu stören, mit der Serverprüfung für jeden Stack.
- [Rate-Limits](https://ikiform.com/de/guides/spam-protection/rate-limiting.md): Begrenze Formulareinsendungen pro Besucher mit einem gemeinsamen Speicher, antworte mit 429 und Retry-After und sperre keine Büros aus, die sich eine IP teilen.
