Prevent AI Bots from spamming your forms with honeypots

Learn how to implement a honeypot to protect your form from spam submissions by AI or regular bots.


Almost everyone who has a form on their website has faced the same frustration: You create or activate a form, and within a day you get your first submission. You’re excited, until you open it and see that some Nigerian Prince is telling you you’ve won the lottery.
Spam submissions are extremely common and annoying. To prevent them, many people use CAPTCHAs, which ask users to solve a small challenge to prove they’re human. While CAPTCHAs work, they hurt the user experience because they add extra effort for visitors, in my opinion.
A much simpler technique is the honeypot. It requires no extra work from real users and is very effective against bots in my experience.
#What is a honeypot in web development?
A honeypot is a hidden trap for bots. You add an extra form field that is invisible to human users but visible to bots. Since bots tend to fill out every available field, they will reveal themselves by filling out the hidden one.
#How does a honeypot work?
Imagine you have a simple contact form:
<form action="/submit" method="POST">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<label for="message">Message:</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send</button>
</form>
Bots crawl websites, detect forms like this, and submit spam.
With a honeypot, you add an extra <input>
field, something like "company". You hide it with CSS so users never see it. Bots, however, will fill it out, and you can ignore those submissions.
<form action="/submit" method="POST">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<label for="message">Message:</label>
<textarea id="message" name="message" required></textarea>
<!-- Honeypot field, hidden from users but visible to bots -->
<div style="display:none;">
<label for="company">Company:</label>
<input type="text" id="company" name="company">
</div>
<button type="submit">Send</button>
</form>
Up on validation (best on server-side), you simply check whether the honeypot field has been filled in and ignore the submission if it has.
// Server-side validation (Node.js example)
app.post("/submit", async (req, res) => {
const { email, message, company } = req.body;
if (company) {
// Handle spam submission
}
res.send("Form submitted successfully!");
});
#Best practices for honeypots
Here are a few tips that made honeypots effective for me:
#Do not return an Error
If the honeypot is filled out, do not return an error response. Just act as if the submission was successful. This way, bots don’t learn they’ve been caught.
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const honeypot = formData.get("company");
if (honeypot !== undefined) {
return new Response("Successfully submitted", {
status: 200,
});
}
}
#Use a generic field name
Choose a common field name like "company" or "website", something that looks normal for your form but isn’t actually needed.
#Hide a parent container, not the field itself
Instead of hiding the input directly, place it inside a parent container (e.g., a <div>
) and hide that container with an unobtrusive class such as form-item
.
.form-item {
display: none;
}
When hiding a field by simply setting display: none
on the field itself, bots may be able to detect the honeypot.
#Set tabindex
to -1
Set tabindex="-1"
on the honeypot input so users can’t reach it with the keyboard, which could lead to the honeypot being filled out.
#Use autocomplete="one-time-code"
Setting the autocomplete
attribute to "one-time-code"
on the honeypot input field helps prevent browsers from autofilling the field with saved data. This is important because if a browser autofills the honeypot field, it could lead to false positives in spam detection, as legitimate users might inadvertently fill out the hidden field.
autocomplete="off"
doesn’t always work reliably, especially in Chrome.
"one-time-code"
is supported across modern
browsers
and ensures the honeypot stays empty.
#Example: Newsletter signup with React Router 7
Here’s a simplified version of my own newsletter signup form:
import type { action } from "@/routes/api/newsletter/signup";
import type { FormProps } from "react-router";
export const schema = z.object({
email: z.email("Please enter a valid email address."),
company: z.string().optional(),
});
export default function NewsletterForm(props: FormProps) {
const { Form, data } = useFetcher<typeof action>();
return (
<Form method="POST" action="/api/newsletter/signup" {...props}>
<div className="group/form-item hidden flex-col gap-2">
<label>
Company
<input
type="text"
name="company"
autoComplete="one-time-code"
tabIndex={-1}
/>
</label>
</div>
<Label>
Email
<Input placeholder="Enter your email" />
</Label>
<Button type="submit">Join newsletter</Button>
</Form>
);
}
And here’s the server-side action that processes the form submission and checks the honeypot field:
import { schema } from "@/components/NewsletterForm";
import { parseWithZod } from "@conform-to/zod/v4";
import type { Route } from "./+types/signup";
import { track } from "@vercel/analytics/server";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
const { email, company: honeypot } = submission.value;
if (honeypot !== undefined) {
await track("honeypot-triggered", {
form: "newsletter-signup",
input: honeypot,
});
return submission.reply();
}
// Signup functionality goes here...
}
And here are the tracked honeypot events after two months:

As you can see, the honeypot caught a significant number of spam submissions without inconveniencing real users and I haven’t received a single spam email through this form since implementing it.