Stop contact form spam6 min read
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.
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.
- A person fills in the form with whatever they use: a screen, a keyboard or a screen reader.
- The trap is off-screen, out of the tab order and hidden from assistive tech, so they never reach it.
- Its name is one browsers and password managers don't autofill, so it stays empty.
- The post arrives with an empty trap and is received.
- A script bot parses the HTML and finds every input, including the trap.
- It fills them all and posts at once.
- The trap has a value, so the post is spam. The bot still gets a normal success answer.
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.
<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><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.
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.
<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>In a server action, redirect a trapped post to the same thank-you page a real one gets, and skip the save.
"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");
}In an Astro Action, take the trap out of the input and return the normal result when it has a value.
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 };
},
}),
};Read the trap in .validator() and skip the save in the handler. Both paths return the same answer.
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 };
});The route action redirects to the thank-you page either way and only saves an untrapped post.
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");
}The form action saves only when the trap is empty, then redirects with 303 for both.
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;The server route answers 201 with the same body for a trapped post and a real one.
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 };
});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.
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});Same in Vue: keep the trap inside the <form> so new FormData(form) includes it, and let the server check it.
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});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 and HTML forms have the reference.
Check it works
- Load the form with a screen reader on and move through it. The trap is never announced.
- Tab through the form. Focus goes from the last real field straight to the button.
- Let your browser and password manager autofill the form. The trap stays empty.
- 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. - 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 and, if needed, a human check.
Questions
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.