# How to add a honeypot field that bots fill and people don't

> Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.

Published 2026-10-25 · https://ikiform.com/guides/spam-protection/honeypot

Add one extra text input that you hide off-screen, take out of the tab order and wrap in `aria-hidden`, with a name no browser autofills. People never fill it in; bots that fill every input do. On the server, treat any post with a value in it as spam and answer exactly as you would for a real message.

## What's different about a honeypot

A honeypot has to be two things at once. To a bot reading the HTML, it must look like a normal field worth filling. To every person, it must not exist, and that includes people who don't use the form the way you do: someone on a screen reader, someone tabbing with a keyboard, someone whose password manager fills forms for them. Most honeypot bugs come from forgetting one of those people, and the symptom is a real message quietly filed as spam.

Diagram: How a person and a bot meet the same honeypot field

1. A person fills in the form with whatever they use: a screen, a keyboard or a screen reader.
2. The trap is off-screen, out of the tab order and hidden from assistive tech, so they never reach it.
3. Its name is one browsers and password managers don't autofill, so it stays empty.
4. The post arrives with an empty trap and is received.
5. A script bot parses the HTML and finds every input, including the trap.
6. It fills them all and posts at once.
7. The trap has a value, so the post is spam. The bot still gets a normal success answer.

Each safeguard on the top row closes one way a real person could reach the field. Drop any of them and the top row can end in spam too.

## The markup

Put the trap inside the form, next to the real fields, as a plain text input. A `type="hidden"` input doesn't work: bots know people can't edit those, and leave them alone.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>

  <div class="hp" aria-hidden="true">
    <label for="hp-field">Leave this empty</label>
    <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>

  <button>Send</button>
</form>
```

`styles`:

```html
<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

- `aria-hidden="true"` on the wrapper removes the label and input from the accessibility tree, so screen readers don't announce them.
- `tabindex="-1"` keeps the Tab key from landing on an input nobody can see.
- The label says **Leave this empty**. If any tool still exposes the field, the person reading it knows what to do.
- Don't add `required`. A required field that people can't see blocks every honest submit.

## How to hide it

Bots have learned to skip fields that are obviously hidden. Simple scrapers still fill everything, but some check the `hidden` attribute or an inline `display: none` before they type. The further the hiding is from the input itself, the more bots fall for it.

| Technique | Hidden from people | Bots it still catches |
| --- | --- | --- |
| `hidden` attribute or inline `display: none` | Yes | Only the simplest. It is the first thing a smarter bot checks |
| Off-screen class in your stylesheet | Yes | Most. The bot must load and apply your CSS to tell |
| `opacity: 0` or zero size | Mostly. Some magnifiers and focus outlines still reveal it | Most, but riskier for people |
| Added by JavaScript after load | Yes | Bots that run scripts. Plain HTML scrapers never see it, so it misses them |

Use the off-screen class from your stylesheet. It catches the most bots and is the safest for people. If one honeypot stops working, add a second with a different name rather than a cleverer hiding trick.

## Pick a name autofill ignores

Browsers and password managers fill fields by guessing from their `name`, `id`, label and `autocomplete` value. `autocomplete="off"` is a hint, and some browsers ignore it for fields that look like addresses or contact details. So the name does the real work.

| Avoid | Why | Use instead |
| --- | --- | --- |
| `email2`, `phone`, `name`, `address` | Autofill recognises them and fills them for real people | `_gotcha`, `company_website` |
| `honeypot`, `trap`, `bot` | Some bots skip fields with obvious names | A dull, plausible name |
| `url`, `website` alone | Some autofill tools fill a website field from a saved profile | A compound name such as `company_website` |

## Check it on the server

A honeypot only works if the server reads it. A browser-side check is useless, since a bot never runs your script. When the field has a value, answer exactly as you would for a real message, and don't store or forward the post. A different answer tells the bot which field gave it away. The examples below call your own `saveMessage`; swap it for whatever stores or emails the message.

**HTML**

A plain HTML form has no server of its own. Point it at Ikiform and name the trap `_gotcha`, or rename it per form under **Honeypot field**. Ikiform does the server check, and the trap is never stored as an answer.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>

  <div class="hp" aria-hidden="true">
    <label for="hp-field">Leave this empty</label>
    <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>

  <button>Send</button>
</form>
```

**Next.js**

In a server action, redirect a trapped post to the same thank-you page a real one gets, and skip the save.

`app/contact/actions.ts`:

```ts
"use server";

import { redirect } from "next/navigation";

export async function sendMessage(formData: FormData) {
  if (String(formData.get("company_website") ?? "").trim() !== "") {
    console.info("honeypot", { at: new Date().toISOString() });
    redirect("/contact/thanks");
  }
  await saveMessage({
    email: String(formData.get("email") ?? ""),
    message: String(formData.get("message") ?? ""),
  });
  redirect("/contact/thanks");
}
```

**Astro**

In an Astro Action, take the trap out of the input and return the normal result when it has a value.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { z } from "astro/zod";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      email: z.email(),
      message: z.string().min(1),
      company_website: z.string().nullish(),
    }),
    handler: async ({ company_website, ...fields }) => {
      if (company_website?.trim()) return { sent: true };
      await saveMessage(fields);
      return { sent: true };
    },
  }),
};
```

**TanStack Start**

Read the trap in `.validator()` and skip the save in the handler. Both paths return the same answer.

`src/server/send-message.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";

export const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return {
      email: String(data.get("email") ?? ""),
      message: String(data.get("message") ?? ""),
      trapped: String(data.get("company_website") ?? "").trim() !== "",
    };
  })
  .handler(async ({ data: { trapped, ...fields } }) => {
    if (!trapped) await saveMessage(fields);
    return { ok: true };
  });
```

**React Router**

The route action redirects to the thank-you page either way and only saves an untrapped post.

`app/routes/contact.tsx`:

```ts
import { redirect } from "react-router";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  if (String(form.get("company_website") ?? "").trim()) return redirect("/contact/thanks");
  await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
  return redirect("/contact/thanks");
}
```

**SvelteKit**

The form action saves only when the trap is empty, then redirects with `303` for both.

`src/routes/contact/+page.server.ts`:

```ts
import { redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request }) => {
    const form = await request.formData();
    if (!String(form.get("company_website") ?? "").trim()) {
      await saveMessage({ email: String(form.get("email")), message: String(form.get("message")) });
    }
    redirect(303, "/contact/thanks");
  },
} satisfies Actions;
```

**Nuxt**

The server route answers `201` with the same body for a trapped post and a real one.

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<{ email?: string; message?: string; company_website?: string }>(event);
  if (body.company_website?.trim()) {
    setResponseStatus(event, 201);
    return { sent: true };
  }
  await saveMessage({ email: body.email ?? "", message: body.message ?? "" });
  setResponseStatus(event, 201);
  return { sent: true };
});
```

**React**

A single-page app can't check the trap itself. Send it with the other fields, and let the server you post to decide. `FormData` includes it with no extra code.

`submit.ts`:

```ts
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
  method: "POST",
  headers: { Accept: "application/json" },
  body: new FormData(form),
});
```

**Vue**

Same in Vue: keep the trap inside the `<form>` so `new FormData(form)` includes it, and let the server check it.

`submit.ts`:

```ts
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
  method: "POST",
  headers: { Accept: "application/json" },
  body: new FormData(form),
});
```

> Log trapped posts, even if you don't store them. A count per day tells you whether the honeypot still earns its place, and a sudden drop to zero means bots have learned to skip it.

## How Ikiform handles the honeypot

Every Ikiform form has a honeypot named `_gotcha` by default. Change it per form under **Honeypot field** in the form's settings; whatever name you pick is reserved and never stored as an answer. A filled honeypot adds 5 to the spam score, which reaches the threshold of 5 by itself.

Ikiform doesn't drop the post. It keeps it as spam, so if a real person ever trips the trap, you can find the message and restore it. A post judged as spam gets the same success answer as a real one, so a bot learns nothing. It is stored with the status `spam`, sends no notification email and no webhook, doesn't count toward your usage, and is deleted after 30 days. Every Monday, a workspace that blocked spam gets one summary email with the total, the busiest forms and the top reasons. The [spam docs](https://ikiform.com/docs/spam.md#honeypot) and [HTML forms](https://ikiform.com/docs/html.md#honeypot) have the reference.

## Check it works

1. Load the form with a screen reader on and move through it. The trap is never announced.
2. Tab through the form. Focus goes from the last real field straight to the button.
3. Let your browser and password manager autofill the form. The trap stays empty.
4. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
5. Send a normal message. It arrives as received, with no trace of the trap field.

## Gotchas

- **JSON posts.** If you build the body by hand instead of with `FormData`, include the trap, or your server reads it as empty for bots too.
- **Form builders that rename fields.** Some builders prefix or rename inputs. Check the posted body, not the editor, for the trap's real name.
- **Caching.** If a cached page still has an old trap name after you rename it, posts arrive with the old name and pass. Rename on both sides at once.
- **Checkbox honeypots.** A hidden checkbox also works, but screen readers handle stray checkboxes worse than text inputs. Prefer a text input.
- **One layer.** Bots that run a real browser and skip invisible fields walk past any honeypot. Pair it with a [rate limit](https://ikiform.com/guides/spam-protection/rate-limiting.md) and, if needed, a [human check](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md).

## Questions

### Do honeypot fields still work against spam bots?

Yes, against the most common ones: scripts that fill every input they find. Bots that run a full browser and skip invisible fields get past, so use a honeypot as the first layer, not the only one.

### Is a honeypot field bad for accessibility?

Only if it is hidden carelessly. Wrap it in an element with aria-hidden, give the input tabindex -1, label it Leave this empty, and never mark it required. Screen readers and keyboards then skip it.

### Why did a real message get caught by my honeypot?

Usually autofill. A browser or password manager filled the trap because its name looked like a real field, such as email2 or phone. Rename it to something no autofill recognises.

### Should I tell the sender their message was rejected?

No. Answer a trapped post exactly like a real one. An error tells the bot which field caught it, and the next version of the bot skips that field.

## More guides

- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.
- [Without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md): Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.
- [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md): Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
- [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md): Compare reCAPTCHA alternatives for forms, from Turnstile and hCaptcha to proof-of-work and no captcha at all, then switch with a small, safe change.
- [Static sites](https://ikiform.com/guides/spam-protection/static-site.md): Stop spam on a static site form even though it can't keep a secret or count posts, with a honeypot partial, a safe script and server-side checks.
