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

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

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.

Diagramm: Ein Burst von Posts eines Besuchers gegen ein gleichmäßiges Limit pro Besucher

1. Ein Besucher sendet 3 Posts innerhalb von 10 Sekunden. Alle werden angenommen.
2. Der nächste wird mit 429 und einem Retry-After-Header abgelehnt, der sagt, wie lange zu warten ist.
3. Danach ist alle 12 Sekunden ein weiterer Post erlaubt.
4. Nach einer vollen ruhigen Minute ist das ganze Kontingent zurück.

Das sind die Standard-Zahlen von Ikiform: 5 pro Minute, höchstens 3 alle 10 Sekunden. Ein gleichmäßiges Auffüllen bedeutet, dass niemand ein ganzes Fenster lang auf einen weiteren Post warten muss.

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

`lib/limit.ts`:

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

**HTML**

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.

`contact.html`:

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

**Next.js**

Prüfe das Limit ganz oben in der Server Action, vor jeder anderen Arbeit.

`app/contact/actions.ts`:

```ts
"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." };
}
```

**Astro**

`context.clientAddress` liefert auf einer On-Demand-Seite die IP des Besuchers. `TOO_MANY_REQUESTS` wird zu einem `429`.

`src/actions/index.ts`:

```ts
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 };
    },
  }),
};
```

**TanStack Start**

Setze Status und `Retry-After` in der Serverfunktion, damit `fetch`-Aufrufer sie lesen können.

`src/server/send-message.ts`:

```ts
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." };
  });
```

**React Router**

Mit `data()` kann die Action einen `429` mit Headern zusammen mit der Meldung für das Formular zurückgeben.

`app/routes/contact.tsx`:

```ts
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." };
}
```

**SvelteKit**

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

`src/routes/contact/+page.server.ts`:

```ts
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;
```

**Nuxt**

`getRequestIP` mit `xForwardedFor` liest die Client-IP hinter einem Proxy. Speichere den Helfer in `server/utils`, damit er automatisch importiert wird.

`server/api/contact.post.ts`:

```ts
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 };
});
```

**React**

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.

`contact.js`:

```js
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;
});
```

**Vue**

Dieselbe Behandlung in einer Vue-Komponente: `Retry-After` lesen, den Button deaktivieren und in einer Ref herunterzählen.

`src/components/ContactForm.vue`:

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

`wrangler.toml`:

```toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
```

`src/index.ts`:

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

`nginx.conf`:

```text
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](https://ikiform.com/de/docs/rate-limits.md#refill) erklärt das Auffüllen.

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

## Prüfen, ob es funktioniert

1. Sende das Formular mehrmals hintereinander ab. Über dem Limit bekommst du `429` mit einem `Retry-After`-Header oder die Bremsseite von Ikiform.
2. Warte die Sekunden aus `Retry-After` ab und sende erneut. Der Post wird angenommen.
3. Sende aus einem zweiten Netz, etwa von deinem Handy ohne WLAN. Es hat sein eigenes Kontingent.
4. 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_limited`** Ein Besucher hat mehr gesendet, als das Rate-Limit des Formulars erlaubt. Standard erlaubt 5 pro Minute und Besucher. Warte `Retry-After` ab 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

### Wie viele Formulareinsendungen pro Minute sollte ich erlauben?

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.

### Kann ich auf Vercel oder Netlify im Arbeitsspeicher begrenzen?

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.

### Was sollte ein begrenztes Formular anzeigen?

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.

### Begrenzt Ikiform Formulare standardmäßig?

Ja. Jedes Formular startet mit dem Preset Standard, 5 pro Minute und Besucher, und du kannst pro Formular auf Relaxed, Strict oder Custom umstellen.

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