Stop contact form spam6 min read
How to rate limit form submissions
Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
Count posts per visitor IP in a store every server instance shares, such as Redis, allow a small burst, and answer 429 with a Retry-After header when the count runs out. An in-memory counter only works on one long-running server, never on serverless hosts. Set the limit from how real people use the form, and leave room for offices and events where many people share one IP.
What's different about rate limits
A rate limit doesn't know spam from mail. It stops volume: a script posting in a loop, a bot cycling through your form, or a person hammering the button. That makes it the cheapest layer, since it runs before you read the body, and the bluntest, since it can't tell an attacker from a busy office. Three things decide whether it helps or hurts: where the count lives, what you count by, and how fast the allowance comes back.
- A visitor sends 3 posts within 10 seconds. All are accepted.
- The next one is refused with 429 and a Retry-After header saying how long to wait.
- After that, one more post is allowed every 12 seconds.
- After a full quiet minute, the whole allowance is back.
Where the count lives
| Store | Works on | Watch out for |
|---|---|---|
A Map in memory | One long-running server | Lost on restart. Each serverless instance has its own map, so the real limit is unknown |
| Redis, such as Upstash | Anything, including serverless and edge | One network call per post. Pick a region near your server |
| Cloudflare Workers rate limiting binding | Workers | Approximate and counted per Cloudflare location, with 10 or 60 second periods only |
A reverse proxy, such as nginx limit_req | Servers behind your own proxy | Counts per proxy. Needs the real client IP |
The algorithm matters less than the store, but it shapes what visitors feel. A fixed window resets on the minute, so a script can send a full allowance at the end of one minute and another at the start of the next. A sliding window smooths that edge by weighing the previous window. A token bucket, or the similar algorithm Ikiform uses, refills one post at a time, which lets a person who made a typo send again a few seconds later without opening the door to a burst. For a contact form, any of them works if the numbers fit how people use it.
The examples below use Upstash's @upstash/ratelimit, because it works the same in every framework and on serverless hosts. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, and save the helper once:
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 is a Unix time in milliseconds, so the helper turns it into whole seconds for Retry-After. Posts with no IP share one bucket, which keeps a missing header from becoming a free pass.
Apply it in your framework
A plain HTML form has no server to count anything. Post it to Ikiform and pick a preset on the form; a visitor over the limit sees a short slow-down page instead of an error.
<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>Check the limit at the top of the server action, before any other work.
"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 gives the visitor's IP on an on-demand page. TOO_MANY_REQUESTS maps to a 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 };
},
}),
};Set the status and Retry-After from the server function, so fetch callers can read them.
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." };
});data() lets the action return a 429 with headers alongside the message for the form.
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() reads the IP from your adapter. fail(429) keeps the form on the page with the message. Save the helper as src/lib/limit.ts; SvelteKit 3 imports it through the #lib alias 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 with xForwardedFor reads the client IP behind a proxy. Save the helper in server/utils to auto-import it.
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 };
});A single-page app can't enforce a limit, but it should respect one. Read Retry-After and count down instead of letting people press the button again. A 429 quota_exceeded has no Retry-After, so it falls through to the error message.
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;
});The same handling in a Vue component: read Retry-After, disable the button, and count down in a ref.
<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>On Cloudflare Workers, the built-in binding is the cheapest 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 documents that binding as approximate and counted separately in each location, so a spread-out attack can exceed it. For an exact limit across locations, Cloudflare recommends a Durable Object. Behind your own nginx, limit_req does the counting before your app sees the post:
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;
}
}What to count by
- IP and form together. The usual choice. One visitor can't flood this form, and using another form on your site doesn't spend the same allowance.
- Many people, one IP. Offices, schools, mobile carriers and conference Wi-Fi put many people behind one address. Keep the per-IP limit loose enough for them, or raise it for event days.
- The real client IP. Behind a proxy or CDN, read the header your host sets and trust only that one. A raw
X-Forwarded-Forvalue can be set by the client. - Your own server's IP. If your server forwards posts to another service, every visitor shares your server's IP there. Limit on your side, by the visitor's IP, before forwarding.
Ikiform's rate limits
Ikiform counts every post exactly, per visitor IP and form, before it reads the body, with a smooth refill rather than fixed windows. Each form picks a preset under Rate limit. A flood guard of 600 a minute per IP across all forms runs before any of them.
| Preset | Per visitor | Suits |
|---|---|---|
| Standard | 5 a minute, at most 3 every 10 seconds | Most forms |
| Relaxed | 20 a minute | Events on shared Wi-Fi, forms behind a login |
| Strict | 2 every 10 minutes, one more every 5 minutes | Contact and sales forms |
| Custom | 1 to 1,000 per 10 seconds, minute, 10 minutes, hour or day | One entry per person, kiosks |
The whole form also takes 300 a minute across all visitors unless you set a Custom cap. Over the limit, a browser post sees a slow-down page and fetch gets 429 rate_limited with Retry-After, which Ikiform exposes through CORS so the code above can read it. The rate limits docs explain the refill.
IKIFORM_SECRET_KEY in server-only environment variables.Check it works
- Submit the form several times in a row. Past the limit you get
429with aRetry-Afterheader, or Ikiform's slow-down page. - Wait the number of seconds in
Retry-Afterand submit again. It is accepted. - Submit from a second network, such as your phone off Wi-Fi. It has its own allowance.
- If your server forwards posts, submit from two devices quickly and check neither shares the other's count.
Gotchas
- Refused posts shouldn't cost allowance. Count before the work, and don't let a refused post push the window further out, or a nervous visitor locks themselves out.
- Don't limit the page load. Count submissions, not views of the form, or one person reloading uses up their sends.
429 rate_limitedOne visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait forRetry-After, or choose Relaxed or Custom for forms that post from one shared IP.- IPv6. One person can hold many IPv6 addresses. For a hard limit, count by the
/64prefix rather than the full address.
Questions
For a contact form, a handful per visitor is plenty: Ikiform's Strict preset allows 2 every 10 minutes. Signup and event forms used from shared networks need more, around 20 a minute.
Not reliably. Each serverless instance has its own memory and instances come and go, so the count resets at random. Use a shared store such as Redis.
A plain message saying how long to wait, taken from the Retry-After header, with the submit button disabled until then. Don't clear what the person typed.
Yes. Every form starts on the Standard preset, 5 a minute per visitor, and you can switch to Relaxed, Strict or Custom per form.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.