Spam im Kontaktformular stoppen7 Min. Lesezeit
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.
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.
- Entscheide, wie die Kontaktseite gerendert wird.
- Mit eigenen Turnstile-Schlüsseln wird die Seite bei Bedarf über einen Adapter gerendert.
- Das Formular sendet an eine Astro Action, die nach IP begrenzt, das Token verifiziert und die Felder weiterleitet.
- Ikiform speichert die Nachricht und bewertet den Honeypot.
- 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.
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
src/
actions/
index.ts
lib/
limit.ts
turnstile.ts
pages/
contact.astro
astro.config.mjs
.envFü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:
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_tokenimport 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/serverlässt sich nicht in einem Client-Skript laden.scriptDirective.resourcesersetzt 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 mitastro buildundastro 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:
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:
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
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,
URLSearchParamsbraucht also keine Header. - Der Hostname wird mit
context.url.hostnameverglichen, dem Host, an den der Besucher tatsächlich gesendet hat. Test-Tokens tragen einen Dummy-Hostnamen, daher wird die Prüfung unterastro 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
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
isInputErrorzurück auf die Seite. - Der Honeypot
_gotchaist 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
Origindes Besuchers wird mitgeschickt, weil ein serverseitiges fetch keinen sendet und Ikiform ihn liest, um kostenloselocalhost-Tests zu erkennen. TOO_MANY_REQUESTSwird zu einem429undFORBIDDENzu einem403.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Seite
---
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:
---
---
<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 zeigt den Rest.
Prüfen, ob es funktioniert
- Starte
astro devmit den Testschlüsseln und sende eine Nachricht. Sie erscheint unter Submissions. Ein Workspace ohne Tarif nimmt 50localhost-Tests pro Monat an. - Lass den Namen leer, nachdem du das
required-Attribut in den Devtools entfernt hast. Der Feldfehler erscheint unter dem Eingabefeld. - Setze
TURNSTILE_SECRET_KEYauf das Secret, das immer scheitert, und sende ab. Die Seite zeigt den Fehler der Menschlichkeitsprüfung. - Sende sechsmal in einer Minute ab. Beim sechsten Mal kommt
429mit der Limit-Meldung. - 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. - Führe
astro buildundastro previewaus, ö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
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.
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.
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.
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.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.