Stop contact form spam

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.

Preet SutharFounder, Ikiform

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 burst of posts from one visitor against a smooth per-visitor limitposts 1 to 3within 10seconds:acceptedpost 4429 withRetry-Afterevery 12 sone more postalloweda quietminutefull allowanceagainburstwaitrefillA burst of posts from one visitor against a smooth per-visitor limitposts 1 to 3within 10 seconds: acceptedpost 4429 with Retry-Afterevery 12 sone more post alloweda quiet minutefull allowance againburstwaitrefill
  1. A visitor sends 3 posts within 10 seconds. All are accepted.
  2. The next one is refused with 429 and a Retry-After header saying how long to wait.
  3. After that, one more post is allowed every 12 seconds.
  4. After a full quiet minute, the whole allowance is back.
These are Ikiform's Standard numbers: 5 a minute, at most 3 every 10 seconds. A smooth refill means nobody waits a whole window for one more post.

Where the count lives

StoreWorks onWatch out for
A Map in memoryOne long-running serverLost on restart. Each serverless instance has its own map, so the real limit is unknown
Redis, such as UpstashAnything, including serverless and edgeOne network call per post. Pick a region near your server
Cloudflare Workers rate limiting bindingWorkersApproximate and counted per Cloudflare location, with 10 or 60 second periods only
A reverse proxy, such as nginx limit_reqServers behind your own proxyCounts 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:

lib/limit.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 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.

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

On Cloudflare Workers, the built-in binding is the cheapest option:

wrangler.toml
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"

  [ratelimits.simple]
  limit = 5
  period = 60
src/index.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 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:

nginx.conf
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-For value 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.

PresetPer visitorSuits
Standard5 a minute, at most 3 every 10 secondsMost forms
Relaxed20 a minuteEvents on shared Wi-Fi, forms behind a login
Strict2 every 10 minutes, one more every 5 minutesContact and sales forms
Custom1 to 1,000 per 10 seconds, minute, 10 minutes, hour or dayOne 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.

The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

Check it works

  1. Submit the form several times in a row. Past the limit you get 429 with a Retry-After header, or Ikiform's slow-down page.
  2. Wait the number of seconds in Retry-After and submit again. It is accepted.
  3. Submit from a second network, such as your phone off Wi-Fi. It has its own allowance.
  4. 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_limited One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-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 /64 prefix rather than the full address.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.