formpit

Spam

How to Stop Contact Form Spam Without a CAPTCHA

The usual advice for contact form spam is "add a CAPTCHA". It works, but it charges your real visitors for a problem bots create: puzzles, slower pages, failed attempts on mobile and a real barrier for people using screen readers. Most form spam can be stopped without any of that.

The trick is to stop thinking of spam protection as one wall and start thinking of it as a few cheap filters, each catching a different kind of bot. Below are the layers that pull their weight, with code, roughly in the order you should add them.

Know what you're fighting

Contact form spam comes from three sources, and each needs a different defence:

  • Dumb bots crawl the web, find any <form>, fill every field and submit. This is the bulk of it, and the easiest to stop.
  • Scripted senders skip your page entirely and post straight to your form's endpoint, often from many IP addresses. Front-end tricks don't affect them.
  • Humans, usually paid to paste SEO pitches and "I noticed your website" messages. No bot check stops a person; content filtering does.

Each layer below is labelled with what it catches.

Layer 1: a honeypot field (catches dumb bots)

Add a field real people never see. Bots that fill in every input give themselves away.

<div class="hp" aria-hidden="true">
  <label for="website">Leave this empty</label>
  <input id="website" type="text" name="website"
         tabindex="-1" autocomplete="off">
</div>

<style>
  .hp { position: absolute; left: -9999px; width: 1px;
        height: 1px; overflow: hidden; }
</style>

Getting the details right makes a big difference:

  • Pick a tempting name. Bots love fields called website, url or company. Avoid obvious names like honeypot that some bots know to skip.
  • Hide it with CSS, not type="hidden". Many bots ignore hidden inputs but happily fill visible-looking ones. Moving it off-screen also hides it from people.
  • Keep it away from people and tools: tabindex="-1" skips it when tabbing, aria-hidden keeps it from screen readers, and autocomplete="off" stops browsers auto-filling it, which would flag a real person.

On the server, if the field has any value, treat the submission as spam.

Layer 2: a time trap (catches fast bots)

A person needs at least a few seconds to read and fill a form. A bot needs milliseconds. Record when the form was shown and reject anything impossibly fast:

<input type="hidden" name="_t" id="form-time">
<script>
  document.getElementById('form-time').value = Date.now();
</script>

Then on the server, check the gap:

const shownAt = Number(req.body._t);
const seconds = (Date.now() - shownAt) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;
const stale   = seconds > 60 * 60 * 24;   // a day-old page: likely replayed

Use three seconds or so, not more; fast typists and browser autofill are real. Also decide what happens when the field is missing. That usually means JavaScript didn't run, which is mostly bots but occasionally a person with scripts blocked. Treat it as one point of suspicion rather than an automatic reject.

Can't a bot fake the timestamp? Yes, if someone writes a bot for your form specifically. These layers are about raising the cost of spam, not making it impossible. Most bots are generic and never bother.

Layer 3: content checks (catches humans and smart bots)

This is the layer that stops pasted SEO pitches, which no bot check will. Look at what was actually written:

  • Links. Real contact messages rarely contain several URLs. Three or more is a strong spam signal.
  • Known phrases. "SEO services", "guest post", "backlinks", "crypto", "first page of Google". Keep a list you can add to.
  • Gibberish. Long strings that are mostly symbols or random characters.
  • Mismatched fields. A URL in the name field, or a message identical to one received minutes ago.
function contentScore({ name = '', message = '' }) {
  const text = `${name} ${message}`.toLowerCase();
  let score = 0;
  const links = (text.match(/https?:\/\//g) || []).length;
  if (links >= 3) score += 3;
  if (/https?:\/\//.test(name)) score += 3;
  for (const w of ['seo services', 'guest post', 'backlinks', 'crypto']) {
    if (text.includes(w)) score += 2;
  }
  const letters = (text.match(/\p{L}/gu) || []).length;
  if (text.length > 40 && letters / text.length < 0.3) score += 3;
  return score;   // e.g. 3 or more = spam
}

Be careful with rules that punish legitimate people: don't block a whole language or a country because some spam comes from it. Score suspicious signals and combine them instead of rejecting on any one.

Layer 4: rate limits (catches floods)

Allow a sensible number of submissions per IP address per form, for example 10 in 10 minutes, and flag the rest. A real visitor never hits that. It won't stop a spammer rotating IP addresses, but it stops one runaway script from filling your inbox, and it limits the damage if a page gets hammered.

Layer 5: only accept your own site (catches lazy scripts)

Check the request's Origin or Referer header and reject submissions that claim to come from a different website. A determined sender can fake these headers, so treat this as a filter against lazy scripts, not a lock. It is still worth a single setting.

What to do with suspected spam

Two habits matter as much as the filters themselves:

  1. Don't tell the bot. Return the same success response a real visitor gets, then quietly file the message as spam. An error message teaches a spammer which field to change. Returning success costs you nothing.
  2. Quarantine, don't delete. Every filter has false positives. Put flagged messages in a spam folder you can check now and then, and keep a "not spam" button. Losing a real customer's message is worse than reading the odd bit of junk.

When a CAPTCHA is actually worth it

If someone targets your form specifically, with a bot that fakes timestamps, rotates IP addresses and writes plausible text, the lighter layers will start to leak. That is the moment to add a challenge, and modern ones are far less painful than the old "click every traffic light":

  • Cloudflare Turnstile runs checks in the background and usually passes people without any puzzle.
  • hCaptcha has a similar low-friction mode and a privacy-focused reputation.
  • Google reCAPTCHA v3 is invisible and gives a score, but it loads Google's scripts on your page, which matters for privacy notices.

Whichever you pick, the token must be verified on the server with the provider's secret key. A widget on the page that nobody checks protects nothing.

How Formpit handles this for you

If you'd rather not build all of this, it's what a form backend should do for you. Formpit layers it the same way: honeypot fields, link and phrase checks, a gibberish check, per-IP rate limits, your own blocked-word list, an optional domain lock, and an optional CAPTCHA (hCaptcha, reCAPTCHA or Turnstile, using your own keys). Flagged messages go to a Spam tab rather than disappearing, and bots get the same success response as everyone else.

You can paste any message into the free spam protection tester to see how it would be treated, and it also scores how many of these defences your current form has.

Summary

  1. Add a well-hidden, temptingly named honeypot.
  2. Add a time trap of about three seconds.
  3. Score the content: links, phrases, gibberish.
  4. Rate-limit per IP and accept only your own site.
  5. Return success to bots, quarantine instead of deleting.
  6. Reach for Turnstile or hCaptcha only when someone targets you.

Together these stop the vast majority of spam while your real visitors never see a single puzzle.

Frequently asked questions

Do honeypot fields still work?

Yes, against the bulk of automated spam. They are invisible to people and filled in by simple bots. Combine them with other checks, because smarter bots skip them.

Should I tell a bot its submission was rejected?

No. Return the same success response you give real visitors and quietly file the message as spam. An error teaches the bot what to change.

Which CAPTCHA is least annoying?

Cloudflare Turnstile and hCaptcha both offer modes that usually pass people without a puzzle. Add one only if lighter checks stop being enough.

More from the blog

How to Add a Contact Form to a Static Website (No Backend Needed)HTML Form Not Working? 11 Common Mistakes and How to Fix ThemHow to Verify Webhook Signatures in Node.js (HMAC-SHA256, Done Right)