The default answer to form spam is to add a CAPTCHA, and it works. It also asks every legitimate visitor to prove they aren't a robot before they're allowed to talk to you, loads a third-party script on a page whose entire job is making a good impression, and tells that third party who is visiting your contact page.
For a form that receives a handful of real messages a month, that's a poor trade. The three checks below stop the overwhelming majority of automated submissions, cost a real visitor exactly nothing — no puzzle, no delay, no script — and send no data anywhere. They're what runs on the contact form on this site, so this is a description of working code rather than a pattern I like the sound of.
I'll also be specific about what each one doesn't stop, because the failure mode of writing about this is implying you've solved a problem you've only made expensive.
1. The honeypot, done so it doesn't hurt anyone
A honeypot is a form field that no human can see. Bots parse the HTML, find an input, and fill it, because filling every field is how they work. Anything arriving with that field populated is automated, and you drop it.
The naive version is display: none and a field called honeypot, and it has two problems. Bots have been reading form markup for twenty years and know what display: none means. And — this is the one that matters — a hidden field is not hidden from a screen reader unless you tell it so. A blind visitor gets an unlabelled text box read out, fills it in honestly because they have no way to know it's a trap, and gets silently classified as a bot.
That's a real accessibility failure with a real victim, and it needs four things:
- Position it off-screen rather than
display: none. Some bots specifically skip undisplayed fields. aria-hidden="true"so assistive technology skips the element and its children entirely.tabIndex={-1}so it can't be reached by keyboard. Without this a keyboard user tabs into a field they can't see.autoComplete="off"so a password manager doesn't helpfully fill it and flag a real person.
Name it something plausible and boring — company, website — so it looks like a field worth filling. A field called honeypot is a field bots skip.
What it doesn't stop: anything driving a real browser. A headless Chrome applies your stylesheet, sees the field is off-screen, and leaves it alone. The honeypot catches the high-volume, low-effort majority, which is almost all form spam by count and none of it by sophistication.
2. Minimum fill time
Record when the form was rendered, send that timestamp with the submission, and reject anything that arrives implausibly fast. This site uses three seconds.
The reasoning is that nobody reads a form, composes a message of at least twenty characters and submits it in under three seconds. A bot posts the moment it has parsed the page. The check costs one hidden value and one subtraction, and it catches scripts that got past the honeypot because they only fill visible fields.
One detail worth handling: a negative elapsed time means the visitor's clock disagrees with the server's, not that they submitted before the page loaded. Treat only thetoo fast case as suspicious and let clock skew through. Otherwise you're rejecting people whose laptop clock is wrong, which is a genuinely bewildering experience to be on the receiving end of.
What it doesn't stop: a script that waits four seconds. This is a speed bump, not a wall. It's worth having because it's nearly free, not because it's strong.
3. A rate limit, and an honest note about it
The first two checks stop casual spam. Neither stops someone who has read your page source and scripted a correct submission — right field names, honeypot left empty, a polite pause before posting. Against that, the only thing that bounds the damage is a ceiling on how many times one source can submit.
This site allows five submissions per IP per fifteen minutes: generous for a person who mistypes their email and resends twice, hostile to a script. Check it before parsing the body, so a flood costs you as little work as possible.
Now the part that usually goes unsaid. On serverless, the obvious implementation is a Map in module scope — and that map lives in one warm instance. It is per-instance, not global. It reliably catches a burst from a single source, which is what abuse actually looks like, and it does nothing against someone spraying from many addresses or patient enough to spread requests across cold starts.
That's still worth shipping. But it should be written down in the code, because the failure mode of an in-memory rate limiter is that it looks like a global one until the day it matters. When it genuinely matters, the fix is a shared store — Vercel KV, Upstash, any Redis — and doing that before you need it is complexity you're carrying for nothing.
One more thing that belongs here: expire old entries as you go, or the map grows forever. A rate limiter that leaks memory is its own denial of service.
Validate the fields, but loosely
Length bounds do quiet work. A minimum message length — this site uses twenty characters — rejects the "nice site!" link-drop genre outright. A maximum stops someone pasting a megabyte into your email provider.
Be careful with email validation specifically. Every clever regex rejects some real address, and the internet is full of people whose perfectly valid address doesn't match the pattern someone found on Stack Overflow. Check that it's an address shape — something, an @, something, a dot, something — cap it at the RFC 5321 maximum of 254 characters, and let delivery decide the rest. Whether mail to it bounces is the real test and you can't run it at submit time anyway.
Don't tell them which check they failed
When a submission is rejected as spam, the endpoint here returns success. The bot is told the message went through; it just doesn't arrive.
This is deliberate. A distinct error is a signal, and a signal is how someone tunes past your checks — submit, read the error, adjust, repeat. Silence gives them nothing to iterate against. It also means a real person caught by a false positive doesn't get an accusatory message, which matters more than it sounds like: the worst outcome of a spam filter is telling a potential client they look like a robot.
Genuine validation errors are different and should be specific and helpful — "a little more detail helps, twenty characters minimum" is a message for a human who can act on it. The distinction is between "you made a mistake" and "you are not who you say you are". Only the second one gets silence.
What this adds up to
Three checks, no third-party script, no puzzle, nothing sent anywhere. A real visitor types their message and presses send, exactly as they expect to. A bot submitting blind trips the honeypot, one submitting fast trips the timer, and one that gets past both hits a ceiling that stops it mattering.
If you become a specific target rather than a random one, this isn't enough, and at that point a CAPTCHA on a failed attempt — rather than on everyone, upfront — is a reasonable escalation. But most sites aren't targeted. They're crawled by scripts that fill every field they find, and those scripts are stopped by a text box nobody can see and a stopwatch.