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.

Post ThumbnailPost Thumbnail

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:

index.html
<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.

index.html
<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.

main.js
// 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.

A little note from me

I put a lot of effort into creating content that is informative, engaging, and useful for my readers. If you find it valuable, please consider subscribing to my newsletter.

#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.

Why not use autocomplete=off?

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:

app/components/NewsletterForm.tsx
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:

app/routes/api/newsletter/signup.ts
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:

Honeypot events tracked with Vercel Analytics

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.