Form honeypot technique that holds up
By Nick Peplow ·
The honeypot is the best return on ten lines of code in form spam defense: an extra field that humans never see and bots fill anyway, because bots fill everything. Filled field, filtered submission. Zero friction for real visitors.
It’s also widely implemented in a way that quietly stopped working. The tutorial version — <input name="honeypot" style="display:none"> checked in JavaScript before submit — fails on three separate counts: the name announces itself, the hiding method is the easiest one for bots to detect, and the check runs in a place bots never visit. Each of those is worth getting right.
What the field actually is
A honeypot is a decoy input added to your form. Your CSS hides it from people; a bot parsing raw HTML doesn’t know that. When a submission arrives with the decoy filled, it wasn’t a person.
The name matters more than most write-ups admit. Spam bots fill fields whose names look valuable — url, website, phone, email2 — and some skip fields with suspicious names. Calling it honeypot, hp_field, or spam_check is labeling your trap. Pick something plausible and tempting: ref_url, company_website, fax_number.
The implementation
Here’s a version that accounts for bots, autofill, and assistive technology at once:
<form action="/submit" method="POST">
<!-- Honeypot: humans never see or fill this -->
<div class="form-extras" aria-hidden="true">
<label for="ref_url">If you are human, leave this field empty</label>
<input type="text" id="ref_url" name="ref_url"
tabindex="-1" autocomplete="off">
</div>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
.form-extras {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
Every attribute in there is load-bearing, so take them one at a time.
Why not just display:none?
Because display:none is the first thing a smarter bot checks. Bots come in two populations. Raw HTTP bots fetch your HTML, parse out the fields, and POST — they never apply CSS, so any hiding method beats them. Headless-browser bots run real Chromium via Puppeteer or Playwright, render your page, and can interrogate it before filling anything.
The cheapest visibility check in a rendered page is element.offsetParent === null — true for any element inside display:none, and a stock one-liner in scraping code. Computed-style checks for display and visibility are just as cheap. Off-screen positioning defeats both: the field is rendered, has an offsetParent, has computed display: block, and sits 9,999 pixels off the left edge where no human will ever scroll. A bot can still catch it by comparing bounding-box coordinates against the viewport, but that’s a rarer, more deliberate check than the offsetParent one-liner.
None of this makes a honeypot bulletproof against headless browsers — a bot that carefully diffs what’s visually present will always find the decoy. The point is to maximize which slice of the bot population you catch. Off-screen positioning catches everything display:none catches, plus the bots running the standard visibility filter.
One trade-off to know about: an off-screen field is still in the page, which is exactly why the accessibility attributes below aren’t optional.
The false positives: autofill and screen readers
A honeypot’s failure mode isn’t missing bots — it’s flagging humans. Two mechanisms fill fields the user never touched.
Browser autofill. Chrome and Safari heuristically fill fields whose name, id, or label look like address-book data — and Chrome ignores autocomplete="off" on fields it recognizes as address fields, which is documented behavior, not a bug. Your defenses are the attribute plus a name the heuristics won’t match. fax_number flirts with autofill’s pattern-matching; ref_url doesn’t. Password managers are more aggressive than browsers here, which is one more reason the honeypot should never be type="email" or type="tel".
Screen readers. CSS off-screen positioning is the standard technique for content that’s meant for screen readers, so your honeypot is fully present in the accessibility tree unless you remove it. That’s what aria-hidden="true" on the wrapper does. tabindex="-1" keeps keyboard users from tabbing into an invisible field and typing into the void. And the label — “If you are human, leave this field empty” — is the backstop for any assistive tech or reader mode that surfaces the field anyway. Belt, braces, and a written instruction.
Where the check runs decides whether it works
A client-side check — JavaScript inspecting the field before allowing submit — protects you from nothing. Bots don’t fill out your form in a browser and press your button; they POST directly to your endpoint. Code that runs before submission never executes for them. The check has to live where the POST lands:
// Express — the only place the check counts
app.post("/submit", async (req, res) => {
if (req.body.ref_url) {
await storeAsSpam(req.body); // keep it for review
return res.json({ success: true }); // don't tip off the bot
}
await handleSubmission(req.body);
res.json({ success: true });
});
Return a normal success response for trapped submissions. A distinct error is feedback — it tells the bot operator which field tripped the wire, and the fix on their end takes minutes. Store the flagged submission somewhere reviewable instead of dropping it, because the day autofill causes a false positive, you’ll want to see the message a real customer thought they sent you.
How much does it actually catch?
Be honest about the two populations. Against raw HTTP bots — the overwhelming majority of contact form spam, because HTTP requests cost the operator close to nothing — a well-built honeypot catches nearly everything. Against headless-browser bots, it degrades: the standard-visibility-check bots still fall for off-screen fields, but a bot that renders and screenshots will walk past any honeypot you build.
That’s not a flaw in the technique; it’s the technique’s actual shape. A honeypot doesn’t out-think bots. It prices them out. Defeating it requires running a full browser per submission — orders of magnitude more compute than a bare POST — and spam economics run on volume. The bots that can afford to beat your honeypot are the minority, and they’re the reason rate limits and invisible challenges exist as the next rungs.
FormWire runs this check server-side on every form by default — trapped submissions are filed in a spam tab, never emailed, never counted against quota, and reviewable in case a false positive slips in. If you’re building the form anyway, the ten lines above are the highest-leverage ten lines you’ll write: they don’t need to stop every bot, just the ones that weren’t willing to pay to reach you.