A contact form goes live on a Tuesday. By Friday it has produced forty submissions, thirty-eight of which are advertising search engine optimisation services, and the client has stopped reading the inbox.
That is the real cost of form spam. Not server load, not storage — the client no longer trusts the notification, so the two genuine enquiries sit unread among the noise. A form nobody reads is a form that does not work.
It is one of the eight things in every build, and it is about twenty minutes of work spread across three layers.
Why not just use a CAPTCHA?#
Because the traditional version taxes every human to stop a machine, and the machines have been better at it than people for years.
Selecting traffic lights across nine tiles takes eight to fifteen seconds and fails often enough to require a second attempt. On a contact form that is a meaningful share of people abandoning, and the ones most affected are those using screen readers or on poor connections.
The modern alternatives — Turnstile, and the invisible modes of the older services — assess signals in the background and only present a challenge when something looks unusual. For the overwhelming majority of visitors there is nothing to do, which is the correct experience.
The framing I hold to: spam protection should cost the spammer something and the visitor nothing. Anything requiring effort from a legitimate user has the incentive backwards.
What are the three layers?#
Each catches a different class of submission, and each is cheap enough that there is no reason to pick one.
| Layer | Stops | User friction | Effort |
|---|---|---|---|
| Honeypot field | Naive form-filling bots | None | Ten minutes |
| Turnstile | Most automated abuse | Near none | Thirty minutes |
| Rate limit | Floods and scripted retries | None for humans | Twenty minutes |
| Content heuristics | What gets through | None | Ongoing |
| Manual review | Targeted, human-driven abuse | None | Continuous |
The first three take under an hour combined and stop the overwhelming majority. The last two are for what remains, and on a small site what remains is usually a handful a month rather than a problem.
Do honeypot fields still work?#
Against unsophisticated bots, yes, and those are still most of the volume.
The technique is a field that is present in the markup and hidden from humans. A bot filling every input it finds fills that one too; a person never sees it. A submission with it populated is discarded.
<!-- Hidden from sight and from assistive technology, present in the DOM. -->
<div aria-hidden="true" style="position:absolute;left:-9999px" >
<label for="company_url">Leave this blank</label>
<input id="company_url" name="company_url" type="text"
tabindex="-1" autocomplete="off">
</div> Three details make it work properly. Hide it with positioning rather than display:none, since some bots specifically skip hidden inputs. Set tabindex="-1" and aria-hidden so keyboard and screen-reader users never encounter it. And name it something plausible — email_confirm, company_url — because a field named honeypot is a field bots learn to skip.
A second variant catches more: a timestamp when the form was rendered, checked on submission. A form completed in under two seconds was not completed by a person reading it. Both are free and neither is visible.
How does Turnstile differ?#
It assesses behavioural and browser signals rather than asking anyone to identify a bicycle, and it is free at any volume.
The widget runs in the background, produces a token, and your server verifies that token against Cloudflare before accepting the submission. Most visitors see a brief automatic tick and nothing else. Where signals are ambiguous, a lightweight interactive challenge appears.
The reason it is in my stack rather than a competitor: no cost at any scale, no visible challenge for typical users, and no ad-tech relationship attached to visitors who never consented to one. On a contact form that last point is worth something.
It is not perfect. A determined operator using a real browser and paying humans will get through, and that is true of every option. The realistic goal is eliminating automated volume, not making submission impossible.
One practical caveat: the widget is a third-party script, so anything blocking it blocks your form. Aggressive privacy extensions, corporate networks with restrictive egress rules and some regions all produce visitors who genuinely cannot load it. That is a small proportion and it is not zero, which is the entire argument for the fallback described later rather than a theoretical concern.
It also has an implicit dependency worth knowing about. If verification is unreachable — the provider having an incident, or your server unable to make outbound requests — every submission fails closed. Deciding in advance whether that is acceptable, or whether the form should accept and flag when verification is unavailable, is better than discovering the behaviour during an outage.
Why is server-side verification mandatory?#
Because everything on the client is a suggestion, and a bot posting directly to your endpoint never runs any of it.
The widget produces a token. That token means nothing until your server exchanges it with the provider and receives confirmation. Skipping that step — rendering the widget and trusting its presence — is the single most common implementation mistake, and it provides no protection whatsoever against anything posting directly.
// The token is worthless until the server checks it.
const verify = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: new URLSearchParams({
secret: env.TURNSTILE_SECRET,
response: token,
remoteip: request.headers.get('cf-connecting-ip') ?? '',
}),
});
const { success } = await verify.json();
if (!success) return json({ error: 'verification_failed' }, { status: 400 }); The same reasoning applies to the honeypot and the timestamp: both are checked on the server, because both are trivially removable by anything not using a browser. This is the same principle as validating anything crossing a boundary — the client is a convenience, never a control.
Where do rate limits fit?#
Catching what the other two miss — the same submission repeated, or a script hammering an endpoint regardless of what it returns.
Two limits are worth having. A per-IP limit of a handful of submissions an hour, which no legitimate user reaches. And a global limit, which protects against a distributed flood where each address is individually unremarkable.
Both belong on the server, and both should fail quietly. A rate-limited bot receiving a clear "too many requests" learns something; one receiving what looks like success learns nothing and stops trying. For genuine users the limit is invisible, since nobody sends five contact form submissions in an hour.
Keying the limit deserves a moment of thought. Address alone is imperfect — an office behind one shared connection looks like a single sender, and a distributed script looks like hundreds of different ones. Combining the address with something else present in the request, and keeping the global ceiling as the backstop, covers both directions better than either key on its own.
This matters most on endpoints that cost you money to process — anything triggering an email, an SMS, or an AI call. A form that sends a notification per submission is a form where volume converts directly into spend.
What about the submissions that get through?#
Filter on content, and accept that a small residue is normal.
Score rather than block#
A submission containing three links, written entirely in a language your business does not operate in, from a domain registered yesterday, is probably spam. Any one signal alone is not. Scoring several and flagging above a threshold is more accurate than any single rule.
Flag, do not delete#
A false positive on a contact form is a lost customer, which is worse than a spam message in a folder. Route suspicious submissions somewhere reviewable rather than discarding them, at least until the rules have proven themselves.
Watch the words that recur#
Most spam to a small site comes from a handful of templates. A short list of phrases, updated occasionally from what actually arrives, catches a disproportionate share for almost no effort.
Make the reply address useless#
Much of this exists to harvest a reply. A notification that does not expose the recipient address, and a form that does not confirm whether an address exists, makes the site a poor target — which reduces the volume over time.
What should the user experience be?#
Invisible when it works, and clear when it does not.
A legitimate submission should involve no extra step. That is achievable now, and any design requiring a person to prove something before contacting you is costing enquiries.
When verification genuinely fails — an old browser, an aggressive extension, a network blocking the widget — the message must say what happened and offer an alternative. "Verification failed" with no path forward converts a false positive into a lost customer.
Always publish a direct email address somewhere. It is the fallback for everyone the form rejects incorrectly, and it costs nothing beyond the spam that address will attract, which is a filtering problem your mail provider already solves well.
What happens after the form is submitted?#
The protection story does not end at acceptance, and two things downstream are worth getting right.
Never echo submitted content into an email unescaped#
A notification rendering user input as HTML is an injection vector, and form submissions are the most reliably hostile input a site receives. Escape it, or send plain text, and treat anything that arrived from outside as untrusted right through to the inbox.
Do not confirm what you know#
A form replying "that email is already registered" tells an attacker which addresses exist. The same applies to password reset and to newsletter signup. Respond identically whether or not the address is known, and put the difference in the email that follows.
Send from your domain, not the submitter's#
Setting the sender to the visitor's address makes replies convenient and makes the message fail authentication, since your server is not authorised to send as their domain. Send from your own address with reply-to set to theirs — the same deliverability discipline that applies to every other transactional message.
Store what you accept#
A submission that only exists as an email is a submission lost when somebody deletes the wrong thing. Writing it to the database as well costs a table and means the enquiry survives an inbox, which clients appreciate more than any anti-spam measure.
How does this apply to other public endpoints?#
Contact forms get the attention and they are rarely the most attractive target on a site.
Newsletter signup#
Frequently abused to send confirmation emails to addresses the sender does not own, using your domain as the delivery mechanism. Double opt-in and rate limiting per address prevent it, and without them your sending reputation pays for somebody else's activity.
Search and filter endpoints#
Cheap to call and sometimes expensive to serve. An unauthenticated search hitting the database on every keystroke is a denial-of-service tool somebody else operates. Rate limit it, and debounce on the client so ordinary use does not resemble abuse.
Anything that costs money per call#
An endpoint triggering an SMS, a document generation or a model call converts requests directly into spend. These deserve authentication rather than only rate limiting, because a limit bounds the damage and an authenticated endpoint prevents most of it.
File uploads#
The most consequential public endpoint on most sites. Constrain type and size before accepting, generate your own filenames rather than trusting the supplied one, and store outside the web root so nothing uploaded can be executed by being requested.
The general rule is that every endpoint reachable without a login should have a stated answer to two questions: what does one call cost, and what stops somebody making a million of them. Most sites have answered neither for anything other than the contact form.
What about forms behind a login?#
Different problem, lighter protection, and worth distinguishing.
An authenticated form has already established a human on the other end at signup. Turnstile there is friction with no benefit, and the honeypot is unnecessary. What still applies is rate limiting, because a compromised or automated account is a real scenario and the limit is invisible either way.
The place to spend effort on an authenticated product is signup itself. That is the public form, and it is where abuse enters — fake accounts consuming free-tier resources, or created in bulk to be sold later. Protecting signup and leaving internal forms open is the correct allocation.
Email verification does most of the remaining work. An account that cannot receive mail at a real address is a considerably less attractive target, and requiring verification before anything expensive can be used prevents the most common abuse pattern.
What does this cost?#
Nothing to run, and under an hour to build.
Turnstile is free at any volume. The honeypot is markup and a conditional. Rate limiting is a counter you likely already have for other endpoints. There is no ongoing subscription for any of it.
The cost people miss is the maintenance of content rules, which drift as spam patterns change. Keeping that list short and reviewing it when something notable gets through is a few minutes a quarter, and elaborate rule sets are not worth building for the volume a small site receives.
The measure is not how many spam messages you blocked. It is whether the client still reads the notifications, because a form nobody reads is a form that does not work.
How do you know it is working?#
Two numbers, and neither is the count of blocked submissions.
The first is how many genuine enquiries arrive per month. If that falls after adding protection, something is rejecting real people, and the block count being impressive is beside the point.
The second is whether the client reads notifications. Ask, rather than inferring it — a client who has muted the notification because of noise has the same outcome as a broken form, and the block count will look excellent throughout.
Log rejections with the reason, at least initially. A rejection log showing everything caught by the honeypot suggests it is working; one showing regular Turnstile failures from ordinary browsers suggests it is rejecting people, which is worth investigating before it becomes normal.
The check I would run at least once on any live form is submitting it yourself, from a phone, on mobile data, with whatever browser you do not normally use. It takes two minutes and it is the only way to be certain the arrangement works for somebody who is not you on the machine it was built on. A form that has only ever been tested by its author in a development environment has not really been tested.
Conclusion#
Three layers, none of which asks a visitor to do anything. A properly hidden honeypot with a plausible name, plus a timestamp check. Turnstile with the token verified on the server, because the client-side widget alone protects nothing. Rate limits per address and globally, failing quietly.
Then score the residue on content, flag rather than delete, and always publish a direct email address for anyone the form wrongly rejects.
Under an hour, no ongoing cost, and it means the client keeps reading their notifications — which is the only outcome that matters, since a contact form is worth exactly as much as the attention paid to what it delivers.
The mistake worth avoiding at the other extreme is over-building this. Spam protection is an area where it is easy to keep adding layers — reputation scoring, behavioural analysis, elaborate content rules — chasing the last few submissions a month. That effort is better spent almost anywhere else, and each additional layer carries a risk of rejecting somebody real. Three layers, a short content filter, and a published email address is the point of diminishing returns for a small business site, and stopping there is the correct decision rather than an incomplete one.