ehsan.blog
~/blog/how-to-stop-spam-with-a-honeypot-field — zsh
cat how-to-stop-spam-with-a-honeypot-field.md

How to stop form spam with a honeypot field

·4 min read

Public contact forms attract spam bots. Most of them are dumb: they load your page, find every input, fill in all of them, and submit. A honeypot turns that behavior against them. You add a field that real users never see or touch, so if it comes back filled in, you know a bot did it. No CAPTCHA, no third-party service, no friction for real people.

Here’s exactly how I wired one into my contact form.

Step 1: add the hidden field to the schema

My Zod schema (which is shared by the client and server) includes a company field. The rule is “must be empty”:

ts
// src/lib/contact-schema.ts
// Honeypot — real users leave this empty; bots tend to fill every field.
company: z.string().max(0).optional().or(z.literal("")),

z.string().max(0) means a string of length zero. The .optional().or(z.literal("")) part lets it also be missing or an explicit empty string, so a legitimate submission that simply omits the field still passes.

Step 2: render a genuinely hidden input

The trick is hiding the field from humans and assistive tech without using type="hidden" — some bots specifically skip hidden inputs, so I want a normal text input that’s just positioned off-screen:

tsx
// src/components/portfolio/ContactForm.tsx
{/* Honeypot — visually hidden, off the tab order, ignored by users. */}
<div
  aria-hidden="true"
  className="absolute -left-[9999px] h-0 w-0 overflow-hidden"
>
  <label htmlFor="cf-company">Company</label>
  <input
    id="cf-company"
    type="text"
    tabIndex={-1}
    autoComplete="off"
    {...register("company")}
  />
</div>

Every attribute here has a job:

  • absolute -left-[9999px] pushes the whole block far off the left edge of the screen, so no sighted user ever sees it.
  • aria-hidden="true" hides it from screen readers, so blind users are never told about a field they shouldn’t fill.
  • tabIndex={-1} removes it from the keyboard tab order, so a keyboard user can’t accidentally land on it.
  • autoComplete="off" stops the browser from helpfully auto-filling it with a saved company name — which would trip the trap on a real user.

To a scraping bot, though, it’s just another text input in the DOM, and it fills it in.

Step 3: handle the trap on the server

The server validates with the same schema, so a filled honeypot already fails validation and returns a 400. But there’s a subtler move for a truly empty-but- present value, and the important lesson is what the handler does when it sees company has content:

ts
// api/contact.ts
const { name, email, message, company } = parsed.data

// Honeypot tripped — respond 200 so bots get no signal, but send nothing.
if (company) return res.status(200).json({ ok: true })

The key idea is the silent 200. When the honeypot is tripped, I don’t return an error. I return 200 OK with { ok: true } — the exact same response a real success gives — but I never send the email. The bot thinks it succeeded and moves on, so it never learns to adapt. If I returned a 400, a smarter bot could detect the block and retry differently.

flowchart TD
  A[Submission arrives] --> B{company field filled?}
  B -- No --> C[Send the email → 200 ok]
  B -- Yes --> D[Send nothing → 200 ok]

Both branches look identical from the outside. That’s the whole point.

A caveat

A honeypot stops the lazy majority of bots, not a determined attacker who reads your HTML. Treat it as a cheap first layer. On this form it sits alongside server-side schema validation and in-memory rate limiting — defence in depth, where each layer catches what the others miss.

What to remember

  • A honeypot is a hidden field humans leave empty and bots fill in.
  • Hide it off-screen with aria-hidden, tabIndex={-1}, and autoComplete="off" — not type="hidden".
  • Validate it as “must be empty” in your shared schema.
  • On a tripped honeypot, return a normal 200 and quietly do nothing, so bots get no feedback to learn from.
  • It’s one layer, not the whole defence.

Related reading: the visually-hidden techniques here overlap with the accessibility patterns in building an accessible form with react-hook-form and Zod.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.