Put a public form on the internet and within a week something will be filling it in that isn't a person. A honeypot is the cheapest way to stop most of that. Four lines of HTML.
It's also the only anti-spam measure I know of whose failure mode is invisible to everyone, including you. I found that out on a client form that quietly threw away five real submissions before anybody worked out why. Everyone who filled it in got a success message. Every time.
So this is a how-to and a warning about the same four lines.
What a honeypot is
A honeypot is a form field that a person never sees and never fills in. It sits in the HTML like any other input. You move it off screen with CSS, hide it from the accessibility tree and take it out of the tab order, so there's no route by which a human being reaches it.
Most spam bots never look at your page. They fetch the HTML, find every input, put something in all of them and post the lot. A bot doing that fills the invisible field too, and that's the tell. If the field arrives with a value, whatever sent it wasn't reading the page.
It's a trap rather than a test, and that's the whole appeal. A CAPTCHA asks your visitor to prove something, which costs them time and costs you the percentage of people who couldn't be bothered. A honeypot asks nothing. When it works, nobody knows it's there.
When it fails, nobody knows that either. Same property, opposite consequence.
Why it's worth doing
An unprotected public form fills with rubbish, and the rubbish isn't free. Every junk submission is a row in a table, a notification, and a few seconds of a real person deciding it's junk. On a contact form that's irritating. On a form somebody is supposed to act on, it's what makes them stop reading the notifications, and then the real one goes past unread.
The economics run in your favour here, which is unusual. Most automated traffic hitting a small site is indiscriminate. It isn't attacking you. It's attacking every form it can find, and it's cheap precisely because it doesn't stop to look at any of them. Anything that requires looking defeats most of it.
That also tells you the ceiling. A honeypot stops the bot that fills everything in. It does nothing against somebody who opens your form, reads it and writes a script for that page. If you're being targeted specifically, this isn't the control you need.
Five things people mean by honeypot
The word covers several techniques that fail in different ways, and the failure mode matters more than the hit rate.
The hidden field
The classic. An empty input that should stay empty. Cheapest to build, and the only one on this list whose failure lands on a real user rather than on a bot.
The timing check
Record when the form was rendered, compare it to when it was submitted, and treat an implausibly fast submission as suspicious. A person reading six labels and typing a phone number takes seconds. A script posts in milliseconds.
This one's my favourite, because it has no user-facing surface at all. There's no field for autofill to fill, nothing for a password manager to find, nothing a screen reader can trip over. Keep the floor low, around three seconds. Somebody pasting a prepared answer is faster than you'd think.
The signed token
The server issues a short-lived signed value with the form and refuses submissions that arrive without a valid one. This stops scripts that post straight at your endpoint without ever fetching the page, which is a large share of the traffic. It costs a round trip and some state, so it suits forms you're already rendering on the server.
The field only JavaScript fills
The server expects a value that a script on the page writes on load. Bots that don't run JavaScript fail immediately. It works, and it also excludes anyone whose JavaScript didn't arrive. On a weak mobile connection that isn't hypothetical, so I don't put this on anything that matters.
An actual challenge
Turnstile, hCaptcha, proof of work. What you reach for when you're genuinely being targeted. The cost is a third party inside your form and a request that can be slow or blocked, so it's worth it once the cheap layers have visibly stopped working and not before.
So which do you actually need? For most small sites, the timing check and the hidden field together, and they compose well: two weak independent signals are much harder to trip by accident than one strong one.
Building one
<div
aria-hidden="true"
style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}
>
<label htmlFor="hp_ref">Leave this field empty</label>
<input id="hp_ref" name="hp_ref" type="text" tabIndex={-1} autoComplete="off" />
</div>Then on the server, when the submission arrives:
const filled = typeof body.hp_ref === 'string' && body.hp_ref.trim() !== '';
const tooFast = Date.now() - Number(body.renderedAt) < 3000;
// Weighted, so neither signal can refuse a submission on its own.
const score = (filled ? 60 : 0) + (tooFast ? 60 : 0);
if (score >= 100) return reject(score);Position it off screen rather than using display:none or an input of type hidden. Both of those are trivial for a bot to detect and skip, and skipping the trap is the one thing you don't want to make easy. Off-screen positioning leaves it a normal, fillable input to anything parsing the page.
The part that will save you
Browser autofill doesn't read your intent, it reads the field name. Mine was called company_website, which felt tidy at the time. Chrome saw a name containing "website", decided it was a URL field it had a saved value for, and filled it in. On a form the user couldn't see it on. My check then read a value in an invisible field and did exactly what I'd told it to do.
Call it something no heuristic recognises. Mine is hp_ref now. Avoid email, phone, url, address, company, name, and anything containing website, because those are the words autofill is looking for. autoComplete="off" helps, but it's a request rather than an instruction, so don't lean on it.
Password managers are the same problem with a different actor. Some fill every text input in a form on demand, so an unlucky name gets a value from them too.
Accessibility is the other half. aria-hidden keeps the field out of the accessibility tree so a screen reader never announces it. tabIndex of -1 keeps it out of the tab order so a keyboard user can't land in it and start typing. Without both, you've aimed the trap at exactly the people least able to work out why their message vanished.
And the rule that would have saved me those five submissions: never let the honeypot be the only reason you throw something away. Score it, as in the snippet above. A filled honeypot on my contact form used to be worth 100 against a threshold of 100, which is a discard wearing the costume of a score. It's 60 now, so it needs corroboration before anything gets refused.
Log every rejection with its reason and score, or you can't tell a bot from a person afterwards
Hold a suspicious submission for review rather than deleting it, wherever you have somewhere to hold it
Test the form twice, once with autofill on and once with it off, and compare what arrives
Weigh the cost of one lost real submission against the cost of one piece of spam, and let that decide how aggressive to be
That last one is the judgement call, and it isn't a technical question. On a public contact form a dropped message is recoverable, because the sender notices the silence and tries again. On a form collecting something once, from people who'll never come back to it, a dropped submission is gone and nobody finds out. I've run a honeypot on the first kind for a year without incident. On the second kind I now run nothing at all, and I think that's right.
How it was actually found
So how did it surface? Not by me reading the code. By the client doing the same thing twice with one variable changed: he filled the form by hand and the row appeared, then filled it with autofill and it didn't. That was the entire diagnosis and it took him about four minutes, while I'd spent considerably longer checking credentials and permissions on a pipe that was working perfectly.
I spent five years in procurement before I wrote software for a living. The rule there is that you never destroy a document because it looks wrong. You mark it, you file it, and a person decides. I knew that, and I still shipped a form that threw away five people without keeping a copy.