# Spamschutz für Astro-Formulare mit Turnstile

> Schütze ein Astro-Formular mit einer Action vor Spam: Honeypot, Turnstile mit astro:env-Secrets geprüft, ein Limit pro IP und die eingebaute CSP von Astro.

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

Setze die Prüfungen in eine Astro Action, an die das Formular ohne JavaScript sendet: Begrenze nach `context.clientAddress`, verifiziere das Turnstile-Token mit einem Secret aus `astro:env/server` und leite dann die Felder mit dem Honeypot weiter. Die Kontaktseite muss bei Bedarf gerendert werden, füge also einen Adapter und dort `prerender = false` hinzu. Wenn du lieber die ganze Site statisch hältst, sende das einfache Formular an Ikiform und lass es dieselben Prüfungen ausführen.

## Was in Astro anders ist

Astro baut standardmäßig statisches HTML, und statisches HTML kann kein Geheimnis verwahren. Ein Turnstile-Secret, ein Ikiform-Secret-Key und ein Rate-Limit-Zähler brauchen alle Code, der pro Request läuft, die erste Entscheidung auf einer Astro-Site ist also, ob die Kontaktseite überhaupt bei Bedarf gerendert wird.

Diagramm: Wählen, wo die Spamprüfungen auf einer Astro-Site laufen

1. Entscheide, wie die Kontaktseite gerendert wird.
2. Mit eigenen Turnstile-Schlüsseln wird die Seite bei Bedarf über einen Adapter gerendert.
3. Das Formular sendet an eine Astro Action, die nach IP begrenzt, das Token verifiziert und die Felder weiterleitet.
4. Ikiform speichert die Nachricht und bewertet den Honeypot.
5. Um die Site statisch zu halten, sendet das einfache Formular direkt an Ikiform, das Honeypot, seine verwaltete Turnstile-Prüfung und das Rate-Limit ausführt.

Beide Wege stoppen dieselben Bots. Der obere gibt dir dein eigenes Widget und dein eigenes Limit; der untere liefert gar keinen Servercode aus.

Astro prüft standardmäßig außerdem den Origin von Formular-Posts an On-Demand-Seiten, über `security.checkOrigin`. Das verhindert, dass die Seite einer anderen Site an deine sendet. Es stoppt kein Skript, das beliebige Header senden kann, die Prüfungen unten gelten also weiterhin.

## Projekt-Setup

`Dateien`:

```text
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env
```

Füge einen Adapter mit `npx astro add vercel`, `netlify` oder `cloudflare` hinzu. Deklariere jede Variable im `astro:env`-Schema, damit ein fehlendes Secret beim Import scheitert und ein Secret nie in Client-Code importiert werden kann:

`.env`:

```bash
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
```

`astro.config.mjs`:

```js
import vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
      TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
    },
  },
  security: {
    csp: {
      scriptDirective: {
        resources: ["'self'", "https://challenges.cloudflare.com"],
      },
      directives: ["frame-src https://challenges.cloudflare.com"],
    },
  },
});
```

- `access: "secret"` hält einen Wert aus dem Bundle heraus. `astro:env/server` lässt sich nicht in einem Client-Skript laden.
- `scriptDirective.resources` ersetzt Astros Standardwerte, also führe `'self'` neben Cloudflare erneut auf. Astro hasht seine eigenen Skripte weiterhin.
- Die CSP läuft nicht unter `astro dev`. Prüfe sie mit `astro build` und `astro preview`.

## Ein Limit pro IP

In einer Action ist `context.clientAddress` die IP des Besuchers, wie dein Adapter sie von der Plattform meldet, du parst `X-Forwarded-For` also nie selbst. Es existiert nur auf On-Demand-Routen und wirft auf vorab gerenderten einen Fehler. Dieser Zähler hält einen Redis-Schlüssel pro IP und Minute, funktioniert also über Serverless-Instanzen hinweg:

`src/lib/limit.ts`:

```ts
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";

const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });

export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
  const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
  return count > limit;
}
```

Auf Cloudflare liest Astro 7 Bindings aus `cloudflare:workers` statt aus `Astro.locals.runtime`. Ein in `wrangler.jsonc` deklariertes Rate-Limiting-Binding von Workers ersetzt Redis ohne Netzwerkaufruf, zum Preis einer ungefähren Zählung pro Standort:

`src/lib/limit.ts`:

```ts
import { env } from "cloudflare:workers";

export async function overLimit(ip: string) {
  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  return !success;
}
```

## Das Token verifizieren

`src/lib/turnstile.ts`:

```ts
import { TURNSTILE_SECRET_KEY } from "astro:env/server";

export async function isHuman(token: string, ip: string, host: string) {
  const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    if (!verdict.success || verdict.action !== "contact") return false;
    return import.meta.env.DEV || verdict.hostname === host;
  } catch {
    return false;
  }
}
```

- Siteverify akzeptiert form-encoded Bodys, `URLSearchParams` braucht also keine Header.
- Der Hostname wird mit `context.url.hostname` verglichen, dem Host, an den der Besucher tatsächlich gesendet hat. Test-Tokens tragen einen Dummy-Hostnamen, daher wird die Prüfung unter `astro dev` übersprungen.
- Ein Token ist 300 Sekunden gültig und funktioniert einmal. Ein einfacher Formular-Post lädt die Seite neu, und Turnstile stellt jedes Mal ein frisches aus.

## Die Action

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us your name.").max(120),
      email: z.email("Enter an address we can reply to."),
      message: z.string().trim().min(10, "A few words more, please.").max(5000),
      _gotcha: z.string().optional(),
      "cf-turnstile-response": z.string().max(2048).optional(),
    }),
    handler: async (input, context) => {
      const ip = context.clientAddress;
      if (await overLimit(ip)) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
      }
      if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
        throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
      }

      const { "cf-turnstile-response": _token, ...fields } = input;
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(fields),
      });
      if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
      return { sent: true };
    },
  }),
};
```

- Zod läuft vor dem Handler, ein fehlerhafter Post kostet also keinen Redis-Aufruf. Feldfehler kommen über `isInputError` zurück auf die Seite.
- Der Honeypot `_gotcha` ist im Schema optional und wird unverändert weitergeleitet. Ikiform bewertet einen ausgefüllten als Spam, du kannst ihn also trotzdem prüfen.
- Das Token wird vor dem Weiterleiten entfernt. Ikiform kann mit einem Token, das du bereits verifiziert hast, nichts anfangen.
- Der `Origin` des Besuchers wird mitgeschickt, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenlose `localhost`-Tests zu erkennen.
- `TOO_MANY_REQUESTS` wird zu einem `429` und `FORBIDDEN` zu einem `403`.

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

## Die Seite

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---

<main>
  <h1>Contact us</h1>
  {result?.data?.sent ? (
    <p role="status">Thanks. We'll reply by email.</p>
  ) : (
    <form method="POST" action={actions.sendMessage}>
      <label for="name">Name</label>
      <input id="name" name="name" autocomplete="name" required maxlength="120" />
      {fields.name && <p class="field-error">{fields.name[0]}</p>}
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      {fields.email && <p class="field-error">{fields.email[0]}</p>}
      <label for="message">Message</label>
      <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
      {fields.message && <p class="field-error">{fields.message[0]}</p>}
      <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>
      <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
      {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
      <button>Send message</button>
    </form>
  )}
</main>

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

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

Das Formular funktioniert ganz ohne Client-JavaScript. `action={actions.sendMessage}` sendet an dieselbe Seite, mit dem Action-Namen im Query-String; Astro führt die Action aus, und `Astro.getActionResult()` liest das Ergebnis beim Rendern. Nur diese Seite wird bei Bedarf gerendert. Der Rest der Site bleibt statisch.

- Neuladen nach einem fehlgeschlagenen Post fragt, ob das Formular erneut gesendet werden soll. Astros Dokumentation schlägt das POST-Redirect-GET-Muster vor, falls dich das stört, mit dem Ergebnis in einer Session.
- Das Turnstile-Skript nutzt `is:inline`, Astro lässt es also in Ruhe. Es muss von Cloudflares eigener URL laden.
- Die Action aus einem Client-Skript auf einer statischen Seite aufzurufen, funktioniert auch, aber dann musst du nach jedem Versuch selbst `turnstile.reset()` aufrufen.

## Deployment

| Host | Adapter | Wo Secrets liegen | Limit |
| --- | --- | --- | --- |
| Vercel | `@astrojs/vercel` | Umgebungsvariablen des Projekts | Redis |
| Netlify | `@astrojs/netlify` | Umgebungsvariablen der Site | Redis |
| Cloudflare | `@astrojs/cloudflare` | `wrangler secret put` | Rate-Limiting-Binding von Workers |

Füge deinen Produktions-Hostnamen vor dem Deployment zum Turnstile-Widget hinzu, und auch Preview-Hostnamen, wenn du dort testest.

## Statisch lassen und Ikiform prüfen lassen

Die meisten Astro-Sites sind statisch, und einen Adapter nur für ein Kontaktformular hinzuzufügen, kostet wirklich etwas. Sende das Formular ohne Adapter direkt an deinen Ikiform-Endpunkt:

`src/components/Contact.astro`:

```astro
---
---

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <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 minlength="10"></textarea>
  <div class="hp" aria-hidden="true">
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>
  <button>Send message</button>
</form>
```

Setze **Spam check** des Formulars auf **Ikiform**. Ein Post ohne Token bekommt eine kurze Prüfseite auf ikiform.com, die Turnstile ausführt und die Felder weiterschickt, die Seite braucht also kein Skript, keine Schlüssel und keine CSP-Änderung. Wähle das **Rate limit** Strict, 2 alle 10 Minuten pro Besucher, und füge deine Domain unter **Allowed domains** hinzu. Spam bekommt dieselbe Antwort wie eine echte Nachricht und wird 30 Tage aufbewahrt, damit du einen Fehlalarm retten kannst. Die [Astro-Einrichtungsanleitung](https://ikiform.com/de/docs/guides/astro.md) zeigt den Rest.

## Prüfen, ob es funktioniert

1. Starte `astro dev` mit den Testschlüsseln und sende eine Nachricht. Sie erscheint unter **Submissions**. Ein Workspace ohne Tarif nimmt 50 `localhost`-Tests pro Monat an.
2. Lass den Namen leer, nachdem du das `required`-Attribut in den Devtools entfernt hast. Der Feldfehler erscheint unter dem Eingabefeld.
3. Setze `TURNSTILE_SECRET_KEY` auf das Secret, das immer scheitert, und sende ab. Die Seite zeigt den Fehler der Menschlichkeitsprüfung.
4. Sende sechsmal in einer Minute ab. Beim sechsten Mal kommt `429` mit der Limit-Meldung.
5. 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.
6. Führe `astro build` und `astro preview` aus, öffne die Seite und prüfe, ob die Konsole keine CSP-Verstöße zeigt.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| `clientAddress` ist auf vorab gerenderten Seiten nicht verfügbar | Die Seite oder die Action lief zur Build-Zeit. Füge der Kontaktseite `export const prerender = false` hinzu. |
| Das Ergebnis der Action ist immer undefined | Das Formular hat an eine vorab gerenderte Seite gesendet. Form-Actions laufen nur auf Seiten, die bei Bedarf gerendert werden. |
| Das Widget ist in der Produktion leer, in der Entwicklung aber in Ordnung | Die CSP blockiert es. `scriptDirective.resources` muss `https://challenges.cloudflare.com` enthalten, und `frame-src` muss es ebenfalls erlauben. |
| `403` ohne Meldung bei jedem Post von einer eigenen Domain | `security.checkOrigin` hat einen anderen Origin gesehen, oft weil ein Proxy den Host umschreibt. Stell sicher, dass der Host-Header, der Astro erreicht, zur Domain der Seite passt. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter **Spam**, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |

## Fragen

### Funktionieren Astro Actions auf einer statischen Site?

Nicht als Form-Action. Ein Formular, das an eine Action sendet, braucht eine bei Bedarf über einen Adapter gerenderte Seite. Auf einer vollständig statischen Site sende das Formular stattdessen an ein Formular-Backend wie Ikiform.

### Wie bekomme ich in Astro die IP des Besuchers?

Nutze context.clientAddress in einer Action oder Astro.clientAddress in einer Seite. Der Adapter füllt sie aus der Plattform. Sie existiert nur auf Routen, die bei Bedarf gerendert werden.

### Unterstützt die CSP von Astro Turnstile?

Ja, mit security.csp, stabil seit Astro 6. Füge https://challenges.cloudflare.com neben 'self' zu scriptDirective.resources hinzu und ergänze eine frame-src-Direktive für denselben Host. Teste mit einem Produktions-Build, da sie in der Entwicklung nicht läuft.

### Ist Ikiform kostenlos?

Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.

## 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.
- [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.
- [Cloudflare Turnstile](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md): 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.
- [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.
- [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.
- [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.
