Learn · Security
How to prevent contact form spam
No single check stops form spam, because spam is not one thing. There are dumb scripts that fill every input, headless browsers that render your page, floods from one address, slow drips from thousands, and humans paid to paste links. Each layer below is cheap on its own and catches a different slice. The point is to know which slice, so you add the next layer for the spam you actually get.
For the reasoning behind skipping reCAPTCHA, see form spam protection without reCAPTCHA. This page is the implementation side.
What each layer stops and misses
Read this before writing code; it decides the order.
- Honeypot field: stops scripts that fill every input. Misses headless browsers that skip invisible fields, and humans.
- Time trap: stops scripts that post the instant they load the page, or post without loading it. Misses slow bots and bots that wait.
- Rate limiting: stops floods from one source. Misses distributed spam spread over many addresses, one submission each.
- Turnstile or another challenge: stops most automated browsers. Misses human spammers, and adds a third party to your page.
- Disposable email blocking: stops throwaway signups and contact spam from temp-mail domains. Misses real mailbox providers and any domain not on the list.
- Server-side validation: stops malformed and oversized payloads, and every check above that only ran in the browser. Misses well-formed spam.
1. Honeypot
An extra text input that people never see and scripts fill. Any value in it means a bot. Hiding it correctly, so screen readers and autofill leave it alone, is the part most snippets get wrong; honeypot fields done right covers it. The server check is one line:
js
if (String(body.hp_field ?? "").trim() !== "") return fakeSuccess(res);2. Time trap
Record when the form was rendered and reject submissions that come back implausibly fast. Sign the timestamp so a bot cannot just send an old value, and keep the threshold low (two or three seconds): password managers and autofill let real people submit quickly.
js
import { createHmac, timingSafeEqual } from "node:crypto";
const KEY = process.env.FORM_TS_KEY;
// When rendering the form: <input type="hidden" name="_ts" value="...">
export function stamp() {
const t = Date.now().toString();
return t + "." + createHmac("sha256", KEY).update(t).digest("hex");
}
// On submit:
export function tooFast(value, minMs = 2500, maxMs = 24 * 3600e3) {
const [t, sig] = String(value ?? "").split(".");
const expected = createHmac("sha256", KEY).update(t ?? "").digest("hex");
if (!sig || sig.length !== expected.length) return true;
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return true;
const age = Date.now() - Number(t);
return age < minMs || age > maxMs;
}This needs a server that renders the page. On a static site the stamp would have to come from JavaScript, which a bot that does not run JavaScript never sends, so you would be rejecting every no-JS visitor too. Treat a failed time trap like the honeypot: store it as spam and answer with a normal success.
3. Rate limiting
Count submissions per form per client in a short window and answer 429 Too Many Requests with a Retry-After header above the threshold. Run it before parsing the body, so a flood costs you a counter lookup instead of a parse and a database write.
The key is usually the client IP. Two details matter: only trust X-Forwarded-For from your own proxy (otherwise the client picks its own key), and you do not need to store the raw address to count it. Hashing IP addresses for rate limiting covers keyed, rotating hashes. Per-IP limits do nothing against spam from many addresses, so a per-form ceiling across all IPs is a useful second counter.
4. Turnstile or another challenge
When the cheap layers are not enough, add a challenge. Cloudflare Turnstile runs in the browser, adds a token to the form, and your server verifies the token with Cloudflare before accepting the submission. The client widget alone protects nothing; the server-side verification is the control. Turnstile on a contact form has the full setup and the privacy trade-off.
5. Disposable email blocking
Match the domain of the submitted email against a list of temporary-mail providers, including subdomains. Lists go stale, so update them from a maintained source rather than hand-curating. Expect false negatives, and think about false positives: a contact form for a privacy-focused product may legitimately get mail from forwarding services.
js
function isDisposable(email, domains /* Set of lowercase domains */) {
const at = email.lastIndexOf("@");
if (at < 0) return false;
const host = email.slice(at + 1).toLowerCase();
for (let d = host; d.includes("."); d = d.slice(d.indexOf(".") + 1)) {
if (domains.has(d)) return true; // mailinator.com, x.mailinator.com
}
return false;
}6. Server-side validation
Every rule the browser enforces (required, type="email", maxlength) can be skipped by posting directly, so repeat the ones that matter on the server: required fields, length caps, a field count cap, and a sane email shape. Reject with 422 and the field name. This is not spam detection on its own, but it bounds what the other layers and your inbox have to handle. See HTML form validation.
Fail quietly, keep the evidence
For the honeypot, the time trap and the block lists, answer exactly as you would for a real submission: same status, same body, same redirect. A distinct error teaches the bot operator which field to stop filling. Store the rejected submission flagged as spam instead of dropping it, so you can check for false positives. Rate limits and challenge failures are different: a real person can hit those, so they get a visible error.
How this works with Formward
Formward runs these layers on every form, in this order: the allowed-origins check, a per-IP rate limit (50 submissions a minute per form by default, adjustable per form), Turnstile when you enable it, the _gotcha honeypot, your block list, and disposable-email blocking when you switch it on. Honeypot, block-list and disposable-email hits are stored as spam with a normal success response and do not count toward your quota.
Field caps apply to every submission (150 fields, 50,000 characters per value). AI spam scoring on Professional and Business adds a content check the rule-based layers cannot do. There is no time trap, because the endpoint never renders your page. Details per layer are in the spam filtering docs.
Sources
Point a form at an EU endpoint
Formward receives the POST, filters spam and stores submissions in Sweden. No server to run.