Spam im Kontaktformular stoppen6 Min. Lesezeit
So begrenzt du Formulareinsendungen per Rate-Limit
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.
Zähle Posts pro Besucher-IP in einem Speicher, den jede Serverinstanz teilt, etwa Redis, erlaube einen kleinen Burst und antworte mit 429 und einem Retry-After-Header, wenn das Kontingent aufgebraucht ist. Ein Zähler im Arbeitsspeicher funktioniert nur auf einem einzigen dauerhaft laufenden Server, nie auf Serverless-Hosts. Setze das Limit danach, wie echte Menschen das Formular nutzen, und lass Spielraum für Büros und Events, in denen sich viele eine IP teilen.
Was an Rate-Limits besonders ist
Ein Rate-Limit unterscheidet Spam nicht von echter Post. Es stoppt Masse: ein Skript, das in einer Schleife sendet, einen Bot, der dein Formular durchprobiert, oder einen Menschen, der auf den Button hämmert. Das macht es zur günstigsten Schicht, weil es läuft, bevor du den Body liest, und zur gröbsten, weil es einen Angreifer nicht von einem vollen Büro unterscheiden kann. Drei Dinge entscheiden, ob es hilft oder schadet: wo der Zähler liegt, wonach du zählst und wie schnell das Kontingent zurückkommt.
- Ein Besucher sendet 3 Posts innerhalb von 10 Sekunden. Alle werden angenommen.
- Der nächste wird mit 429 und einem Retry-After-Header abgelehnt, der sagt, wie lange zu warten ist.
- Danach ist alle 12 Sekunden ein weiterer Post erlaubt.
- Nach einer vollen ruhigen Minute ist das ganze Kontingent zurück.
Wo der Zähler liegt
| Speicher | Funktioniert auf | Achte auf |
|---|---|---|
Eine Map im Arbeitsspeicher | Einem dauerhaft laufenden Server | Geht beim Neustart verloren. Jede Serverless-Instanz hat ihre eigene Map, das tatsächliche Limit ist also unbekannt |
| Redis, etwa Upstash | Allem, einschließlich Serverless und Edge | Ein Netzwerkaufruf pro Post. Wähle eine Region nahe deinem Server |
| Rate-Limiting-Binding von Cloudflare Workers | Workers | Ungefähr und pro Cloudflare-Standort gezählt, nur mit Zeiträumen von 10 oder 60 Sekunden |
Ein Reverse Proxy, etwa nginx limit_req | Servern hinter deinem eigenen Proxy | Zählt pro Proxy. Braucht die echte Client-IP |
Der Algorithmus zählt weniger als der Speicher, prägt aber, was Besucher spüren. Ein festes Fenster setzt sich zur vollen Minute zurück, sodass ein Skript am Ende einer Minute ein volles Kontingent senden kann und zu Beginn der nächsten noch eins. Ein gleitendes Fenster glättet diese Kante, indem es das vorherige Fenster gewichtet. Ein Token Bucket oder der ähnliche Algorithmus, den Ikiform nutzt, füllt einen Post nach dem anderen auf, sodass jemand nach einem Tippfehler ein paar Sekunden später erneut senden kann, ohne einem Burst die Tür zu öffnen. Für ein Kontaktformular funktioniert jeder davon, wenn die Zahlen dazu passen, wie Menschen es nutzen.
Die Beispiele unten nutzen @upstash/ratelimit von Upstash, weil es in jedem Framework und auf Serverless-Hosts gleich funktioniert. Setze UPSTASH_REDIS_REST_URL und UPSTASH_REDIS_REST_TOKEN und speichere den Helfer einmal:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const contact = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "contact",
});
export async function allow(ip: string | null | undefined) {
const { success, reset } = await contact.limit(ip || "no-ip");
return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}reset ist eine Unix-Zeit in Millisekunden, daher rechnet der Helfer sie für Retry-After in ganze Sekunden um. Posts ohne IP teilen sich einen Bucket, damit ein fehlender Header kein Freifahrtschein wird.
In deinem Framework anwenden
Ein einfaches HTML-Formular hat keinen Server, der etwas zählen könnte. Sende es an Ikiform und wähle ein Preset am Formular; ein Besucher über dem Limit sieht eine kurze Bremsseite statt eines Fehlers.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button>Send</button>
</form>Prüfe das Limit ganz oben in der Server Action, vor jeder anderen Arbeit.
"use server";
import { headers } from "next/headers";
import { allow } from "@/lib/limit";
export async function sendMessage(_previous: { message: string }, formData: FormData) {
const forwarded = (await headers()).get("x-forwarded-for");
const limit = await allow(forwarded?.split(",")[0]?.trim());
if (!limit.ok) return { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
await saveMessage(Object.fromEntries(formData));
return { message: "Thanks. We'll reply by email." };
}context.clientAddress liefert auf einer On-Demand-Seite die IP des Besuchers. TOO_MANY_REQUESTS wird zu einem 429.
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { allow } from "../lib/limit";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({ email: z.email(), message: z.string().min(1) }),
handler: async (input, context) => {
const limit = await allow(context.clientAddress);
if (!limit.ok) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
}
await saveMessage(input);
return { sent: true };
},
}),
};Setze Status und Retry-After in der Serverfunktion, damit fetch-Aufrufer sie lesen können.
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader, setResponseHeaders, setResponseStatus } from "@tanstack/react-start/server";
import { allow } from "../lib/limit";
export const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return { email: String(data.get("email") ?? ""), message: String(data.get("message") ?? "") };
})
.handler(async ({ data }) => {
const limit = await allow(getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim());
if (!limit.ok) {
setResponseStatus(429);
setResponseHeaders(new Headers({ "Retry-After": String(limit.retryAfter) }));
return { ok: false, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
}
await saveMessage(data);
return { ok: true, message: "Thanks. We'll reply by email." };
});Mit data() kann die Action einen 429 mit Headern zusammen mit der Meldung für das Formular zurückgeben.
import { data } from "react-router";
import { allow } from "../lib/limit";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const limit = await allow(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim());
if (!limit.ok) {
return data(
{ message: `Too many messages. Try again in ${limit.retryAfter} seconds.` },
{ status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
);
}
await saveMessage(Object.fromEntries(await request.formData()));
return { message: "Thanks. We'll reply by email." };
}getClientAddress() liest die IP aus deinem Adapter. fail(429) lässt das Formular mit der Meldung auf der Seite. Speichere den Helfer als src/lib/limit.ts; SvelteKit 3 importiert ihn über den Alias #lib in package.json.
import { fail } from "@sveltejs/kit";
import { allow } from "#lib/limit.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress }) => {
const limit = await allow(getClientAddress());
if (!limit.ok) return fail(429, { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
await saveMessage(Object.fromEntries(await request.formData()));
return { sent: true };
},
} satisfies Actions;getRequestIP mit xForwardedFor liest die Client-IP hinter einem Proxy. Speichere den Helfer in server/utils, damit er automatisch importiert wird.
export default defineEventHandler(async (event) => {
const limit = await allow(getRequestIP(event, { xForwardedFor: true }));
if (!limit.ok) {
setResponseHeader(event, "Retry-After", limit.retryAfter);
throw createError({ status: 429, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
}
await saveMessage(await readBody(event));
return { sent: true };
});Eine Single-Page-App kann kein Limit durchsetzen, sollte aber eines respektieren. Lies Retry-After und zähle herunter, statt die Leute den Button erneut drücken zu lassen. Ein 429 quota_exceeded hat kein Retry-After, landet also bei der Fehlermeldung.
const button = form.querySelector("button");
const status = form.querySelector("[role=status]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const retryAfter = response.headers.get("Retry-After");
if (response.status === 429 && retryAfter) {
let wait = Number(retryAfter);
button.disabled = true;
const timer = setInterval(() => {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
wait -= 1;
if (wait < 0) {
clearInterval(timer);
button.disabled = false;
status.textContent = "";
}
}, 1000);
return;
}
const result = await response.json();
status.textContent = result.ok ? "Thanks. We'll reply by email." : result.error.message;
});Dieselbe Behandlung in einer Vue-Komponente: Retry-After lesen, den Button deaktivieren und in einer Ref herunterzählen.
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const wait = ref(0);
const notice = ref("");
let timer: ReturnType<typeof setInterval> | undefined;
function countDown(seconds: number) {
wait.value = seconds;
clearInterval(timer);
timer = setInterval(() => {
wait.value -= 1;
if (wait.value <= 0) clearInterval(timer);
}, 1000);
}
async function send(event: SubmitEvent) {
notice.value = "";
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const retryAfter = Number(response.headers.get("Retry-After"));
if (response.status === 429 && retryAfter > 0) {
countDown(retryAfter);
return;
}
const result = await response.json();
notice.value = result.ok ? "Thanks. We'll reply by email." : result.error.message;
}
onBeforeUnmount(() => clearInterval(timer));
</script>
<template>
<form :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required />
<p role="status">{{ wait > 0 ? `Too many messages. Try again in ${wait} seconds.` : notice }}</p>
<button :disabled="wait > 0">Send</button>
</form>
</template>Auf Cloudflare Workers ist das eingebaute Binding die günstigste Option:
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"
[ratelimits.simple]
limit = 5
period = 60const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });Cloudflare dokumentiert dieses Binding als ungefähr und an jedem Standort separat gezählt, ein verteilter Angriff kann es also überschreiten. Für ein exaktes Limit über Standorte hinweg empfiehlt Cloudflare ein Durable Object. Hinter deinem eigenen nginx zählt limit_req, bevor deine App den Post sieht:
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;
server {
location = /api/contact {
limit_req zone=contact burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
}
}Wonach du zählst
- IP und Formular zusammen. Die übliche Wahl. Ein Besucher kann dieses Formular nicht fluten, und die Nutzung eines anderen Formulars auf deiner Site verbraucht nicht dasselbe Kontingent.
- Viele Menschen, eine IP. Büros, Schulen, Mobilfunkanbieter und Konferenz-WLAN setzen viele Menschen hinter eine Adresse. Halte das Limit pro IP für sie locker genug oder erhöhe es an Event-Tagen.
- Die echte Client-IP. Hinter einem Proxy oder CDN liest du den Header, den dein Host setzt, und vertraust nur diesem. Einen rohen
X-Forwarded-For-Wert kann der Client selbst setzen. - Die IP deines eigenen Servers. Wenn dein Server Posts an einen anderen Dienst weiterleitet, teilen sich dort alle Besucher die IP deines Servers. Begrenze auf deiner Seite, nach der IP des Besuchers, bevor du weiterleitest.
Die Rate-Limits von Ikiform
Ikiform zählt jeden Post exakt, pro Besucher-IP und Formular, bevor es den Body liest, mit gleichmäßigem Auffüllen statt festen Fenstern. Jedes Formular wählt unter Rate limit ein Preset. Ein Flutschutz von 600 pro Minute und IP über alle Formulare läuft vor allen anderen.
| Preset | Pro Besucher | Passt zu |
|---|---|---|
| Standard | 5 pro Minute, höchstens 3 alle 10 Sekunden | Den meisten Formularen |
| Relaxed | 20 pro Minute | Events im gemeinsamen WLAN, Formularen hinter einem Login |
| Strict | 2 alle 10 Minuten, alle 5 Minuten einer mehr | Kontakt- und Vertriebsformularen |
| Custom | 1 bis 1.000 pro 10 Sekunden, Minute, 10 Minuten, Stunde oder Tag | Einem Eintrag pro Person, Kiosken |
Das ganze Formular nimmt außerdem 300 pro Minute über alle Besucher an, außer du setzt eine Custom-Grenze. Über dem Limit sieht ein Browser-Post eine Bremsseite, und fetch bekommt 429 rate_limited mit Retry-After, das Ikiform über CORS freigibt, damit der Code oben es lesen kann. Die Dokumentation zu Rate-Limits erklärt das Auffüllen.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Prüfen, ob es funktioniert
- Sende das Formular mehrmals hintereinander ab. Über dem Limit bekommst du
429mit einemRetry-After-Header oder die Bremsseite von Ikiform. - Warte die Sekunden aus
Retry-Afterab und sende erneut. Der Post wird angenommen. - Sende aus einem zweiten Netz, etwa von deinem Handy ohne WLAN. Es hat sein eigenes Kontingent.
- Wenn dein Server Posts weiterleitet, sende schnell von zwei Geräten und prüfe, dass keines den Zähler des anderen teilt.
Stolperfallen
- Abgelehnte Posts sollten kein Kontingent kosten. Zähle vor der Arbeit und lass einen abgelehnten Post das Fenster nicht weiter hinausschieben, sonst sperrt sich ein nervöser Besucher selbst aus.
- Begrenze nicht das Laden der Seite. Zähle Einsendungen, nicht Aufrufe des Formulars, sonst verbraucht eine Person durch Neuladen ihre Sendungen.
429 rate_limitedEin Besucher hat mehr gesendet, als das Rate-Limit des Formulars erlaubt. Standard erlaubt 5 pro Minute und Besucher. WarteRetry-Afterab oder wähle Relaxed oder Custom für Formulare, die von einer gemeinsamen IP aus senden.- IPv6. Eine Person kann viele IPv6-Adressen haben. Für ein hartes Limit zähle nach dem
/64-Präfix statt nach der vollständigen Adresse.
Fragen
Für ein Kontaktformular reichen eine Handvoll pro Besucher: Das Preset Strict von Ikiform erlaubt 2 alle 10 Minuten. Anmelde- und Eventformulare, die aus gemeinsamen Netzen genutzt werden, brauchen mehr, etwa 20 pro Minute.
Nicht zuverlässig. Jede Serverless-Instanz hat ihren eigenen Speicher, und Instanzen kommen und gehen, der Zähler setzt sich also zufällig zurück. Nutze einen gemeinsamen Speicher wie Redis.
Eine schlichte Meldung, wie lange zu warten ist, aus dem Retry-After-Header, mit deaktiviertem Absende-Button bis dahin. Lösch nicht, was die Person getippt hat.
Ja. Jedes Formular startet mit dem Preset Standard, 5 pro Minute und Besucher, und du kannst pro Formular auf Relaxed, Strict oder Custom umstellen.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.