All posts

Honeypot Fields for Signup Forms: How to Build One That Works

··6 min read

A honeypot field is a form input that people can't see or reach, so anything that fills it is a bot. To build one that works, position it off-screen rather than using display:none, hide it from screen readers with aria-hidden, and take it out of the tab order with tabindex="-1". Give it a name that autofill won't match. On the server, discard any submission where the field is filled or missing, and return the normal success response.

We tested the markup in headless Chrome 155 with axe-core 4.14.0 on 6 October 2026, and ran the server handler on Node.js 22. This post goes deeper on the honeypot than our overview of how to stop fake signups, which covers the other layers.

The markup

A wrapper hidden from assistive technology, an input removed from the tab order, and a label for anyone who somehow lands on it.

<div class="hp" aria-hidden="true">
  <label for="hp_note">Leave this field empty</label>
  <input type="text" id="hp_note" name="hp_note" tabindex="-1" autocomplete="off">
</div>
.hp {
  position: absolute;
  left: -10000px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

Use a class rather than an inline style. It looks less like a trap to anything reading the HTML, and it works with a strict Content Security Policy.

Off-screen beats display:none

Bots that check visibility treat a display:none field as hidden and skip it. An off-screen field still counts as visible, so they fill it.

Real bots range from simple scripts that fill every input to headless browsers built on tools like Playwright and Puppeteer. We checked how Playwright's own isVisible() sees each approach:

Hiding method Playwright isVisible() Submitted with the form?
Off-screen (left: -10000px) true Yes
display: none false Yes

Both fields were included in FormData when empty, so either works mechanically. The difference is which bots fall for it. A bot that only fills fields its framework considers visible walks straight past a display:none trap. Avoid type="hidden" for the same reason: no bot mistakes a hidden input for a field a person would fill.

Accessibility: what we tested

Leave out tabindex="-1" and keyboard users tab straight into the trap. Put it in and axe reports no honeypot problem.

We loaded a minimal signup form with each version of the honeypot, ran axe-core 4.14.0's WCAG 2 A, AA and best-practice rules, and pressed Tab from the email field:

Version axe honeypot issues Tab from email field lands on
aria-hidden wrapper, tabindex="-1" on input None Submit button
aria-hidden wrapper, no tabindex aria-hidden-focus The honeypot
display: none None Submit button

The middle row is the classic mistake. axe's aria-hidden-focus rule explains why it matters: such an element "is ignored as part of the reading order, but still part of the focus order". A screen reader user would tab into a field that's never announced, type into it, and get silently dropped as a bot. Deque lists tabindex="-1" as an accepted fix.

The visible label ("Leave this field empty") is a fallback for unusual setups such as user stylesheets or text-only browsers, where the field might appear. Don't write anything that invites the user to fill it, like "Website".

Naming the field: avoid autofill

Browsers and password managers guess what to fill from a field's name, id and label. A honeypot called email2 or phone invites them in.

This is the main source of false positives. A real user's browser autofills the trap, and the server throws away their signup. Two defences:

  • Use a name that means nothing to autofill: hp_note, xq_ref, anything that doesn't resemble a name, email, phone, address, company or URL.
  • Add autocomplete="off", but don't rely on it. MDN's autocomplete reference warns that "Browsers may also ignore autocomplete="off" on login fields to support password managers." Treat it as a hint, not a guarantee.

Then test it yourself. With saved addresses and a password manager installed, autofill the form in Chrome, Safari, Firefox and Edge and check that the trap stays empty.

The server side

Treat a filled trap and a missing trap the same way, and reply with the normal success message.

Browsers submit empty text inputs. As the table above showed, the empty hp_note was present in FormData. So a request that has no hp_note at all didn't come from your form. It's a script POSTing straight to your endpoint, and it deserves the same treatment as a filled trap.

const TRAP = "hp_note";
const SUCCESS = { ok: true, message: "Check your inbox to confirm your address." };

export function trapReason(form) {
  const value = form.get(TRAP);
  if (value === null) return "trap field missing";
  if (String(value).trim() !== "") return "trap field filled";
  return null;
}

export async function handleSignup(request, { createUser, log }) {
  const form = await request.formData();
  const reason = trapReason(form);
  if (reason) {
    log({ event: "honeypot", reason, ip: request.headers.get("x-forwarded-for") });
    return Response.json(SUCCESS);
  }
  await createUser(String(form.get("email") ?? ""));
  return Response.json(SUCCESS);
}

Three test submissions (a human with the trap empty, a bot that filled every field, and a direct POST without the trap) all received the identical 200 success response. Only the human's address reached createUser. The other two were logged, with the reasons "trap field filled" and "trap field missing".

Why return success? A bot that gets an error learns it was caught and its operator adjusts. A bot that gets "Check your inbox" moves on. The same handler shape works in a Next.js Server Action (example), an Express route or a WordPress filter (example).

Run the trap check first, before any database query, DNS lookup or verification API call. That makes it the cheapest rejection in your pipeline, and it keeps bots from making you pay for lookups or API calls.

Measure it, or you won't know it's hurting someone

Log every trap hit with the reason, and review the first few weeks by hand.

Pattern in the log What it suggests
Filled with URLs, gibberish or the same value every time Working as intended
Filled with a real-looking name, phone or address Autofill. Rename the field
Lots of "missing" from one IP range Direct POSTs to your endpoint. Rate-limit that range
Trap hits near zero, but fake accounts keep arriving Bots that skip the trap. Add the next layer

The last row is the honeypot's ceiling. It catches software that fills fields blindly. It doesn't catch a bot written for your form, a headless browser that fills only labelled, visible inputs, or people signing up by hand with throwaway addresses. For those you need rate limits, a server-verified challenge and email confirmation, all covered in how to stop fake signups.

Checklist

  • Wrapper positioned off-screen with a class, aria-hidden="true".
  • Input with tabindex="-1", autocomplete="off", a meaningless name, and a "Leave this field empty" label.
  • Server rejects a filled or missing trap, returns the normal success response, and logs the reason.
  • Check runs before any expensive work.
  • Autofill tested in every major browser and password manager, and logs reviewed for false positives.
  • To check the email addresses that do get through, try the free email verifier.

Common questions

What is a honeypot field on a form?

A form input that people can't see or reach, so only automated software fills it in. If the field arrives with a value, or doesn't arrive at all, the server treats the submission as a bot and quietly discards it. It costs real users nothing, unlike a CAPTCHA.

Should I hide a honeypot with display:none?

Prefer positioning it off-screen. In our test, Playwright's standard visibility check reported a display:none field as hidden and an off-screen field as visible, so a bot that skips hidden fields would skip the display:none trap and still fill the off-screen one. Both kinds of field are still submitted with the form.

Are honeypot fields bad for accessibility?

They can be. Without tabindex=-1, keyboard users can tab into the hidden field, and axe-core reports an aria-hidden-focus violation. With aria-hidden on the wrapper, tabindex=-1 on the input and a label saying to leave it empty, axe reported no honeypot-related issues in our test and Tab skipped straight past it.

Do honeypots stop all spam signups?

No. They stop generic bots that fill every field. A bot written for your site, a headless browser that only fills visible labelled fields, or a person signing up by hand will get past one. Use a honeypot as the first, free layer, alongside rate limits, a server-verified challenge and email confirmation.

Check your list before you send

Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.