How to prevent contact form spam (without CAPTCHA everywhere)
Short answer: Layer cheap, invisible controls first: honeypot fields, per-IP rate limits, origin allow-lists and idempotency. Add a CAPTCHA only for the traffic that survives those, because a CAPTCHA taxes every real visitor to stop a minority of bots.
Spam is not a single problem. There are crawlers that parse every form on the internet, targeted bots that solve your specific challenge, and humans paid to submit junk. Each needs a different lever.
Layer 1: Honeypots (free, invisible)
<label class="sr-only" for="website">Leave this field empty</label>
<input id="website" name="_gotcha" tabindex="-1" autocomplete="off" />
<input type="hidden" name="_honey" value="" />Naive bots fill every input. Formivo answers honeypot submissions with a success response and stores nothing, so the bot has no signal that it failed.
Layer 2: Rate limits that survive scaling
In-memory counters reset with every cold start and diverge across instances. Limits need to live in a shared store. Formivo keeps fixed windows in Postgres and enforces three per submission: per form, per IP, and per form+IP.
Layer 3: Origin allow-lists
If a form is only used on one site, restricting accepted origins removes a large class of drive-by abuse. Be clear about what this is: a browser control, not authentication. A server-side script can send any `Origin` it likes, which is why rate limits and quotas still matter.
Layer 4: Idempotency
Double clicks and retry loops create duplicate leads that look like spam to whoever has to clean them up. Send an `Idempotency-Key` (or `_idempotency_key`) generated once per form fill; retries then return the original submission instead of inserting a second one.
Layer 5: Content heuristics
- Reject submissions whose only URL appears in a link-heavy message.
- Treat identical bodies from the same IP within minutes as one submission.
- Flag free-mail addresses only if your funnel genuinely needs it — usually it does not.
When a CAPTCHA is worth it
| Signal | Action |
|---|---|
| Honeypot catches most bots | No CAPTCHA needed |
| Bots solve your challenge and submit | Add a provider CAPTCHA (Turnstile, hCaptcha) |
| Humans submit junk | Moderation queue, not a CAPTCHA |
A CAPTCHA should be the last layer, because it costs you real conversions and adds a third-party dependency to your form's critical path.
A checklist for launch day
- 1Honeypot field present and hidden accessibly (not `display:none` only).
- 2Rate limits in a shared store, keyed on a trustworthy IP.
- 3Origin allow-list set if the form has one known home.
- 4Quota configured so a flood cannot cost you money silently.
- 5An alert path: someone should notice a spike in the same day.