Skip to main content
Back to Blog
5 min read

The four-line spam filter that replaced reCAPTCHA on my contact form

Honeypot + time-floor + size limits. No third-party script, no privacy footnotes, no checkbox to click. Two months in and I've had zero spam in my inbox.

securitynext.jsux

The contact form on this site fires a Telegram notification whenever someone submits. When I shipped it I assumed I'd need reCAPTCHA — every spam-protection guide on the internet says so. I tried it for a week, hated the friction, and ripped it out.

Two months later, with reCAPTCHA gone, the inbox is still clean. Here's the pattern.

The whole filter, in code

// app/api/telegram/route.ts
const data = await request.json()
const { website, mountedAt, name, email, message } = data

// 1. Honeypot — real users never see this field
if (website && website.trim().length > 0) {
  return NextResponse.json({ success: true }) // silent accept
}

// 2. Time-floor — bots submit instantly, humans need to type
if (typeof mountedAt === 'number' && Date.now() - mountedAt < 1500) {
  return NextResponse.json({ success: true })
}

// 3. Size limits — protect downstream APIs from payload abuse
if (name.length > 200 || email.length > 200 || message.length > 5000) {
  return NextResponse.json({ error: 'Payload too large' }, { status: 413 })
}

That's it. Three guards, all server-side. No client script.

Why each one matters

The honeypot is a hidden form field — <input name="website" tabindex="-1" autocomplete="off" style={{display:'none'}} /> on the React side. Real browsers don't render it, real users don't fill it. Most form-spamming bots crawl the DOM, see an input, and dutifully fill every one. The moment website has any value, you know it's a bot. Critical detail: don't return an error. Return { success: true }. Bots watching for 4xx/5xx will retry; bots that get a 200 mark the form as "done" and move on.

The time-floor catches the rest. The form ships its mount timestamp into a hidden field on render. When the submission arrives, anything under 1.5 seconds between mount and submit is a bot — humans physically cannot type a name, email, and message that fast. Same trick: return 200 silently so the bot doesn't learn to back off.

The size limits aren't really anti-spam — they're abuse protection. Without them, a bored attacker could POST a 50MB body and run up your Telegram API quota. Cap each field to a sane max and return 413 for oversize.

Why no reCAPTCHA

reCAPTCHA solves a real problem in the wrong way for this use case. It:

For a low-volume B2B contact form, the math just doesn't work. The cost of reCAPTCHA (lost legitimate submissions) is higher than the benefit (filtering spam that the honeypot catches anyway).

When this stops working

I'd revisit the moment any of these is true:

For everything in between — a portfolio, a small SaaS contact page, a "tell me more" CTA — start with the four-line version. Add complexity only if it actually breaks.

The full Reactful client-side bit

For completeness, the React side that powers the time-floor and honeypot:

const [mountedAt] = useState(() => Date.now())

return (
  <form onSubmit={handleSubmit}>
    {/* honeypot — visually + semantically hidden */}
    <input
      type="text"
      name="website"
      tabIndex={-1}
      autoComplete="off"
      aria-hidden="true"
      className="absolute left-[-9999px]"
    />
    <input type="hidden" name="mountedAt" value={mountedAt} />

    {/* real fields */}
    <input name="name" required />
    <input name="email" type="email" required />
    <textarea name="message" required />

    <button type="submit">Send</button>
  </form>
)

Three things to get right:

  1. Don't use display: none on the honeypot. Some bots check computed style and skip it. Use absolute positioning off-screen — looks empty to humans, looks like a real field to crawlers.
  2. Mount timestamp is per-render, not per-session. If you cache it in localStorage you'll defeat the time-floor for legitimate returning users.
  3. aria-hidden keeps screen readers from announcing the honeypot. They'd skip a display:none field anyway, but absolute-positioned inputs without aria-hidden can confuse assistive tech.

The lesson, if there is one: spam protection is a UX problem first. The "right" answer in the abstract — the heaviest, smartest filter — is often the wrong answer once you account for the cost it imposes on legitimate users. For a contact form on a personal site, four lines of server-side check is enough. Save the captchas for the threats that actually warrant them.