Honeypots Still Work: 3 Ways to Stop Spam Signups Without CAPTCHAs

Fred· AI Engineer & Developer Educator11 min read

Honeypots Still Work: 3 Ways to Stop Spam Signups Without CAPTCHAs

Last week, I noticed something suspicious in my SEO Friend waitlist signups. Dozens of email addresses from disposable domains, all submitted within milliseconds of each other, originating from IP addresses in Russia and the Netherlands. Classic bot farm behavior.

The obvious solution? Slap a CAPTCHA on the form. But CAPTCHAs are annoying. They frustrate real users, hurt conversion rates, and honestly—modern bots are getting pretty good at solving them anyway. I wanted something invisible to humans but deadly to bots.

Enter the honeypot.

What is a Honeypot?

A honeypot is a trap designed to catch automated systems by exploiting their predictable behavior. In web security, a form honeypot works on a simple principle: bots auto-fill every form field they find, while humans only fill the fields they can see.

If you add a hidden field that humans can't see, and that field gets filled out—you've caught a bot.

It's an old technique, but it still works remarkably well in 2025. Combined with timing checks, you can stop the vast majority of spam signups without any user friction whatsoever.

The Problem: Bot Farm Signups

Before implementing my honeypot, here's what I was dealing with. IPs from data centers in Russia, Netherlands, and Germany were hitting my waitlist form. The submissions came in at inhuman speed—0.2 to 0.5 seconds after the page loaded. The email addresses were all from disposable domains like tempmail and guerrillamail. And they were filling out every single form field they could find, which would become their undoing.

Why do bots do this? They're harvesting email systems—submitting forms to see if confirmation emails bounce back, validating that your email pipeline works. They're polluting databases with junk data to waste your time and email quota. They're testing email addresses for credential stuffing attacks later. Sometimes it's competitor sabotage—inflating your metrics with garbage data so you can't trust your numbers.

The bots were coming from the same /24 IP blocks, submitting within fractions of a second, and using obvious throwaway emails. Perfect candidates for honeypot detection.

Example 1: The Hidden Field Trap

This is the classic honeypot technique that's been stopping bots since the early 2000s. The concept is beautifully simple: add a form field that's invisible to humans but visible to bots. When a bot auto-fills every field on the page (which they always do), they'll fill your hidden field too—and you've caught them.

Here's the HTML you'll add to your form, right alongside your real email input:

<form id="waitlist-form" action="/api/signup" method="POST">
  <!-- The real field that humans fill out -->
  <label for="email">Email Address</label>
  <input type="email" id="email" name="email" required>

  <!-- The honeypot field - completely invisible to humans -->
  <input
    type="text"
    name="website"
    style="position:absolute;left:-9999px"
    tabindex="-1"
    autocomplete="off"
    aria-hidden="true"
  >

  <button type="submit">Join Waitlist</button>
</form>

Every attribute on that honeypot field serves a specific purpose. The type="text" makes it look like a normal text field to any bot parsing your HTML. The name="website" is deliberately innocuous—bots are programmed to fill common fields like "website", "url", "company", and "phone", so using one of these names ensures they'll take the bait. Avoid obvious names like "honeypot" or "trap" because some sophisticated bots look for those patterns.

The style="position:absolute;left:-9999px" is the key to invisibility. By positioning the element 9999 pixels off the left side of the screen, no human will ever see it, but bots parsing the DOM will find it just fine. The tabindex="-1" prevents keyboard users from accidentally tabbing into the field while navigating your form. The autocomplete="off" stops browser autofill from touching it—you don't want Chrome helpfully filling your honeypot with a legitimate user's saved data. Finally, aria-hidden="true" tells screen readers to ignore this field entirely, maintaining accessibility for visually impaired users.

On the server side, the check is trivial. If the website field contains any value at all, you've caught a bot:

app.post('/api/signup', async (c) => {
  const { email, website } = await c.req.parseBody();

  // If the honeypot field has any value, a bot filled it
  if (website) {
    console.log('Bot detected via honeypot:', email);

    // Return fake success - the bot thinks it worked
    return c.json({
      success: true,
      message: 'Thanks for signing up!'
    });
  }

  // Real human - process normally
  await saveToDatabase(email);
  return c.json({ success: true, message: 'Welcome!' });
});

The critical detail here: return a fake success response instead of an error. If you return an error message, sophisticated bots might detect they've been caught and try different approaches—maybe skipping fields, maybe using a different IP. By returning success, the bot's operator sees green checkmarks in their logs and moves on to easier targets. Your database stays clean, and they never know what hit them.

Example 2: The Timing Check

Humans take time to read a page and fill out a form. Even the fastest typist needs several seconds to scan the form, click into the email field, type their address, and hit submit. A real person simply cannot load a page and submit a form in 300 milliseconds.

Bots can. And they do, constantly.

This honeypot technique measures the time between page load and form submission. Anything under 3 seconds is almost certainly automated.

First, add a hidden field to your form that will store the page load timestamp:

<form id="waitlist-form" action="/api/signup" method="POST">
  <label for="email">Email Address</label>
  <input type="email" id="email" name="email" required>

  <!-- Timing check field - captures when the page loaded -->
  <input type="hidden" name="loadedAt" id="loadedAtField">

  <button type="submit">Join Waitlist</button>
</form>

<script>
  // As soon as the page loads, record the timestamp
  document.getElementById('loadedAtField').value = Date.now();
</script>

When the page loads, JavaScript immediately sets the hidden field's value to the current Unix timestamp in milliseconds. This value gets submitted along with the rest of the form data. The user never sees it, never interacts with it—it's completely invisible.

On the server, you compare the submission time to the load time:

app.post('/api/signup', async (c) => {
  const { email, loadedAt } = await c.req.parseBody();

  // Calculate how long the user took to submit
  if (loadedAt) {
    const elapsed = Date.now() - parseInt(loadedAt, 10);

    // Less than 3 seconds? No human types that fast.
    if (elapsed < 3000) {
      console.log(`Bot detected: submitted in ${elapsed}ms`);

      // Return rate limit error - slightly more believable
      return c.json({
        error: 'Please wait a moment before submitting.'
      }, 429);
    }
  }

  // Took a reasonable amount of time - probably human
  await saveToDatabase(email);
  return c.json({ success: true, message: 'Welcome!' });
});

I use 3 seconds (3000 milliseconds) as the threshold. In testing, I found that even fast users with autofill enabled take at least 4-5 seconds to submit. The slowest bots I caught were around 800 milliseconds. Three seconds gives comfortable margin for both.

Why return a 429 (Too Many Requests) error for timing violations instead of fake success? Because timing violations might occasionally catch edge cases—like a user with an extremely fast connection who spam-clicks submit. A "please wait" message is more user-friendly than silently succeeding and not actually signing them up. You can also return fake success here if you prefer; both approaches work.

Example 3: The Complete Two-Part System

In practice, you want both techniques working together. The hidden field catches bots that auto-fill forms. The timing check catches bots that try to be clever and skip hidden fields. Together, they form a nearly impenetrable barrier against automated submissions.

Here's the complete implementation I'm running on SEO Friend:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Join the Waitlist</title>
</head>
<body>
  <form id="waitlist-form">
    <div class="form-group">
      <label for="email">Email Address</label>
      <input
        type="email"
        id="email"
        name="email"
        required
        placeholder="you@example.com"
      >
    </div>

    <!-- HONEYPOT 1: Hidden field trap -->
    <!-- Bots auto-fill this; humans never see it -->
    <input
      type="text"
      name="website"
      style="position:absolute;left:-9999px"
      tabindex="-1"
      autocomplete="off"
      aria-hidden="true"
    >

    <!-- HONEYPOT 2: Timing check -->
    <!-- Records page load time for speed validation -->
    <input type="hidden" name="loadedAt" id="loadedAt">

    <button type="submit">Join Waitlist</button>
  </form>

  <script>
    // Set the timestamp as soon as page loads
    document.getElementById('loadedAt').value = Date.now();

    document.getElementById('waitlist-form').addEventListener('submit', async (e) => {
      e.preventDefault();
      const formData = new FormData(e.target);

      const response = await fetch('/api/signup', {
        method: 'POST',
        body: formData
      });

      const result = await response.json();
      if (result.success) {
        alert('Thanks for joining the waitlist!');
      } else {
        alert(result.error || 'Something went wrong.');
      }
    });
  </script>
</body>
</html>

And the server-side handler that ties it all together:

import { Hono } from 'hono';

const app = new Hono();

app.post('/api/signup', async (c) => {
  const body = await c.req.parseBody();
  const email = body.email;
  const website = body.website;    // Honeypot field
  const loadedAt = body.loadedAt;  // Timing field

  // Get IP for logging (Cloudflare provides this header)
  const ip = c.req.header('cf-connecting-ip') || 'unknown';

  // CHECK 1: Hidden field honeypot
  // If this field has any value, a bot filled it
  if (website) {
    console.log(`[BLOCKED] Honeypot triggered | IP: ${ip} | Email: ${email}`);

    // Fake success - bot doesn't know it failed
    return c.json({
      success: true,
      message: 'Thanks for signing up!'
    });
  }

  // CHECK 2: Timing validation
  // If form was submitted in under 3 seconds, it's automated
  if (loadedAt) {
    const elapsed = Date.now() - parseInt(loadedAt, 10);
    if (elapsed < 3000) {
      console.log(`[BLOCKED] Too fast (${elapsed}ms) | IP: ${ip} | Email: ${email}`);

      return c.json({
        error: 'Please wait a moment before submitting.'
      }, 429);
    }
  }

  // CHECK 3: Basic email validation
  if (!email || !email.includes('@')) {
    return c.json({ error: 'Please enter a valid email.' }, 400);
  }

  // PASSED ALL CHECKS - This is a real human
  console.log(`[SUCCESS] New signup | IP: ${ip} | Email: ${email}`);

  // Save to your database
  await db.insert({
    email,
    ip,
    signedUpAt: new Date().toISOString()
  });

  return c.json({
    success: true,
    message: 'Welcome to the waitlist!'
  });
});

export default app;

The logging is important. By tracking blocked attempts, you can see exactly how many bots you're catching and from where. In my first week, I logged over 200 honeypot triggers from IPs in the Netherlands and Russia. All of them thought they successfully signed up. None of them made it into my database.

Results

After implementing this two-part honeypot system, spam signups dropped from 20-50 per day to essentially zero. The bots are still trying—I see them in my server logs—but they all get caught and fed fake success responses. They think they're winning. My database stays clean.

Real users haven't noticed anything. There's no CAPTCHA to solve, no friction added to the signup flow. They fill out their email, click submit, and they're done. The honeypot checks happen invisibly in the background.

The implementation took about 30 minutes—most of that was setting up the logging so I could monitor what was being blocked. There's no ongoing maintenance, no monthly fees for anti-spam services, no API keys to manage.

Sometimes the simplest solutions are the best ones. Honeypots have been around for decades because they work. In an age of increasingly sophisticated AI and machine learning, there's something satisfying about stopping bots with a hidden text field and a timestamp.

Frequently Asked Questions

Do honeypots work against all bots?

No. Sophisticated bots specifically designed to bypass honeypots may skip hidden fields or add artificial delays. However, these are rare. The vast majority of bots are simple scrapers that auto-fill everything and submit immediately. Honeypots catch 95%+ of automated spam, which is usually enough to make your spam problem manageable.

Will this break accessibility?

Not if implemented correctly. The aria-hidden="true" attribute tells screen readers to ignore the honeypot field entirely, so visually impaired users won't be confused by a field they can't interact with. The tabindex="-1" prevents keyboard users from tabbing into it. Users navigating with assistive technology will experience the form exactly as intended.

What if a legitimate user somehow fills the honeypot?

This is nearly impossible with proper implementation. The field is positioned 9999 pixels off the left edge of the screen—users literally cannot see it or click into it. Browser autofill is disabled with autocomplete="off". In two weeks of production use with thousands of visitors, I've had zero false positives.

Should I use CAPTCHA instead?

CAPTCHAs add friction. Studies show they can reduce conversion rates by 3-8%. Users hate them—I hate them. Honeypots are invisible; users don't even know they exist. Start with honeypots. Only add CAPTCHAs if you're still getting significant spam after implementing proper honeypot protection, which is unlikely for most sites.

Fred

Fred

AUTHOR

Full-stack developer with 10+ years building production applications. I've built frontends that users actually enjoy using (and that don't break in IE).