Stopping form spam without CAPTCHAs: honeypots, timing and fake success
Making a human prove humanity is a UX tax, and the bots pay it more reliably than your customers do. The invisible gate on every form on this site: a honeypot field, a minimum fill time, and a fake success that teaches the bot nothing — three server-side checks, no third-party script.
There are six forms on this website — contact, helpdesk, newsletter, job application, AMA question, content request — and every one of them writes into Odoo. Not one of them shows you a CAPTCHA. No grid of traffic lights, no invisible score from an ad company, no "verify you are human" checkbox in front of someone who just wants to ask us what a project would cost.
They still don't get spam, and the reason is three checks that take about fifteen lines total. The interesting part isn't the code — it's that the standard answer to this problem charges the wrong party.
A CAPTCHA taxes the human and inconveniences the bot
Look at who pays what. A visitor filling in your contact form pays with a few seconds, some irritation, and occasionally a failure they can't recover from — because CAPTCHAs are hostile to screen readers, to anyone on a VPN, to anyone whose browser is unusual enough to look suspicious, and to the perfectly ordinary person who just gets the crosswalks wrong twice. A commercial solver, meanwhile, pays a fraction of a cent per token. The economics are backwards: the check is a real obstacle to the customer you wanted and a line item to the bot you didn't.
Then there are the costs that don't show up as friction:
- A third-party script on the critical path. reCAPTCHA pulls in JavaScript from a domain you don't control, on a page whose Core Web Vitals you're being graded on. It's not a large script, but it's a request to somebody else's origin before your form is usable.
- A GDPR conversation you didn't need. Your contact form now ships visitor data — IP, browsing signals, a behavioural score — to an American ad company, from a Belgian business page, for the purpose of deciding whether the visitor is real. That is a processor, a legal basis and a line in the privacy policy, added in exchange for spam filtering.
- Accessibility that isn't yours to fix. The audio fallback exists and it's bad. When it fails for a user, you have no lever — the component isn't yours, and neither is the failure.
- Lost conversions you never measure. The form that didn't get submitted doesn't appear in any dashboard. This is the cost nobody prices, and on a business site the whole point of the page is that form.
For a marketing site — static, no database, no admin panel, a handful of forms posting to server actions — the spam problem is small enough that paying all of that is absurd. Almost all of it is undirected, automated, low-effort: a script walking a list of domains, POSTing whatever fields it finds. You don't need to prove humanity to beat that. You need to be slightly different from the average form.
The honeypot: a field that only a bot can fill
Every form on this site renders an input nobody can see:
{/* Honeypot: humans never see this field */}
<div aria-hidden className="hidden">
<label>
Leave this empty
<input autoComplete="off" name="confirm" tabIndex={-1} type="text" />
</label>
</div>Four attributes, each doing a specific job, and all four matter:
className="hidden"—display: none, so it never renders. Not off-screen positioning, notopacity: 0, not a 1px box: those are visible to the layout and to anyone reading the page with a magnifier.aria-hidden— a screen reader never announces it. A honeypot that assistive technology reads out is a honeypot that traps the users you care most about not trapping.tabIndex={-1}— keyboard users tab straight past it. Same reasoning.autoComplete="off"— the one people forget, and the one that actually breaks honeypots in production. Password managers and browser autofill are aggressive; give a field a name likeurloremail2and 1Password will helpfully fill it, and you'll silently drop real submissions for months without ever knowing.
That last point drives the field name too. Ours is confirm, and it's deliberately boring — plausible enough that a naive bot filling every input hits it, generic enough that no autofill heuristic recognises it as something to complete. Naming it honeypot would be worse in the other direction: any bot worth worrying about greps for exactly that.
The server side is a string comparison. If confirm came back with anything in it, no human typed it.
Minimum fill time: nobody writes a project brief in three seconds
The second check is timing. A bot POSTs the instant it parses the form. A person reads the labels, thinks about what to write, tabs between fields. That gap is enormous — hundreds of milliseconds against tens of seconds — so it doesn't need a clever threshold.
The client stamps the moment the visitor actually received the form:
export function useStartedAt(): () => number {
const startedAt = React.useRef(0)
React.useEffect(() => {
if (startedAt.current === 0) {
startedAt.current = Date.now()
}
}, [])
return React.useCallback(() => startedAt.current || Date.now(), [])
}Two details in there are worth more than they look. The timestamp is taken in an effect, not during render — because with cacheComponents enabled, Date.now() during a client component's prerender is correctly flagged as unstable, and the SSR HTML would otherwise bake in a build-time clock. Every visitor would then arrive with a form that was, as far as the server could tell, opened at deploy time and therefore always old enough. The gate would still be there, and it would let everything through.
The second is the || Date.now() fallback, which covers a submit that somehow beats hydration. It yields a zero-length fill — which the server already treats as a bot, so the impossible case fails closed instead of open.
The threshold is three seconds, and it's a floor rather than a fingerprint. We're not trying to model human behaviour; we're excluding the physically implausible. Nobody has ever typed a name, an e-mail, a company, an address and ten characters of "tell us about your project" in under three seconds. Push it to fifteen and you'd start catching the fast, decisive people you actually want.
Fake success: never tell the bot it failed
This is the check people skip, and it's the one that keeps the other two working.
/**
* Spam gate shared by both forms: a honeypot field that humans never see
* and a minimum fill time. Bots that fail either get a fake success so
* they learn nothing.
*/
function isSpam(input: { confirm: string; startedAt: number }): boolean {
return input.confirm !== "" || Date.now() - input.startedAt < 3000
}And at the call site, the response to a bot is indistinguishable from the response to a customer:
if (isSpam(parsed.data)) {
return { ok: true }
}{ ok: true }, HTTP 200, the same success panel a real visitor sees. Nothing is written to Odoo, nothing is logged, nothing alerts.
Return a 403 or an error message instead and you've built a free oracle. The operator flips one thing at a time — skip the hidden field, wait five seconds — and your response tells them, immediately and for free, when they got it right. Silent-drop-with-success removes the feedback loop entirely: from the outside, the form is broken in no observable way, so there's nothing to iterate against. The bot moves on to the ten thousand other domains on its list, where a 403 will tell it something useful.
It's the same instinct as not returning "unknown user" on a login form, applied to the other direction of the funnel.
The gate lives on the server, after validation
Order matters, and it's the same in all five action files:
- Zod parses the input. Nothing downstream ever sees a shape it didn't expect.
- The spam gate runs. Fails → fake success, return.
- The delivery happens. Only now does anything reach Odoo.
Putting the gate second is deliberate. Validating first means the spam check reads typed, coerced values rather than raw form data, and it means a malformed submission gets a real, useful error — a bot that can't even produce a valid payload isn't worth a special case.
Putting the gate on the server is not negotiable. Both signals originate in the browser: a bot that never executes your JavaScript can post whatever confirm and startedAt it likes. The checks are cheap heuristics against unsophisticated automation, which is what almost all form spam is — they are not authentication, and doing them client-side would make them decoration.
One honest wart while we're in here: isSpam exists as a named function in forms.ts and again in jobs.ts, and is inlined as a condition in the newsletter, AMA and content-request actions. Five copies of one rule. It has never caused a bug because the rule hasn't changed since it was written, but the day we want a fourth signal it becomes five edits, and that's exactly the shape of thing that ends up applied to four of them.
What this deliberately does not stop
Being clear about the boundary is the difference between a pragmatic choice and a naive one.
- A bot written for your site specifically. Someone who loads the page in a headless browser, skips hidden inputs and waits four seconds walks straight through. Nothing here is a challenge; it's a filter. If you're worth targeting individually, you need a different tool.
- Human-typed spam. Outsourced form-filling defeats every technical check on this page, and a CAPTCHA too.
- Volume abuse. A hundred thousand submissions still cost you a hundred thousand server actions, even if every one is dropped. That's a rate-limiting problem, and it belongs in the ingress in front of the app rather than in application code — the request should die before it reaches a pod. It's a hosting concern, not a form one.
- Anything with a login behind it. Credential stuffing and account enumeration are a different threat with different economics, and that's where a challenge, or better a passkey, genuinely earns its place.
For a public marketing form whose worst case is a junk lead in the CRM, the loss function is mild and the filter is proportionate. For a checkout, a signup that provisions infrastructure, or a password reset, it isn't — reach for something stronger there, and be honest that you're paying for it.
What we actually ship
A hidden input named something forgettable, with autocomplete off so no password manager fills it. A timestamp taken in an effect on the client and a three-second floor on the server. A fake success for anything that trips either. All of it server-side, after Zod, before the ERP write. No third-party script, no processor agreement, no accessibility fallback that isn't ours to fix, and no customer asked to prove they exist before they're allowed to talk to us.
It's roughly an hour of work on a new form and it has held for every custom web application we've shipped it on — including the ones where the client arrived convinced they needed a CAPTCHA because their previous WordPress site drowned in it. That site's problem was rarely the absence of a challenge. It was a plugin-generated form posting to an endpoint that accepted anything, from anyone, at any rate.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.