All articles

How to Stop Contact Form Spam Without a CAPTCHA

Published Updated By GoodInbound team

Stop contact form spam with no CAPTCHA: a honeypot with working code, signed time traps, rate limits, email and content checks, and how to catch human pitches.

You can stop most contact form spam without a CAPTCHA by stacking checks the visitor never sees: a hidden honeypot field, a minimum fill time, a token only JavaScript can fetch, rate limits per IP, and server-side checks on the email address and the message. Bots fail the first four. Fake addresses fail the email check. Sales pitches typed by real people only get caught by reading the message.

Below is working code for each method and what each one misses, so you can run a spam-free contact form with no CAPTCHA at all. For the wider topic, including CAPTCHAs and WordPress plugins, start with how to stop contact form spam.

GoodInbound Lead Screen stops contact form spam without a CAPTCHA. One script on your existing form screens every submission in the background, with nothing for the visitor to solve:

  • Bots and fake sign-ups stopped, judged by how the form was filled in, the network, and the email address.
  • Sales pitches caught, from people and from AI tools, by reading what the message asks for.
  • Every submission labeled with a reason, and nothing is deleted, so a real lead screened out by mistake is one click away.
  • Notifications only for the labels you choose, by email, Slack, or Telegram, and only real leads reach your CRM and ad platforms.

The free plan screens 500 submissions a month; the message check that catches sales pitches is on Pro. Start free, or read on for every method.

Why skip the CAPTCHA

Every puzzle or checkbox is one more step between a buyer and the send button, and the W3C's note on the inaccessibility of CAPTCHA recommends non-interactive methods where possible. A CAPTCHA also only asks whether a human is filling in the form, while much of what fills contact forms now is a human selling something. That's why reCAPTCHA isn't stopping your spam.

The methods below ask nothing of the visitor. Use several, because each catches something the others miss.

1. Add a honeypot field

Honeypot spam protection is the cheapest layer: a field people never see and simple bots fill in. Many bots fill every input they find, so if the hidden field arrives with a value, a bot sent it.

The HTML

<form id="contact" action="/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" type="text" autocomplete="name" required>

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>

  <!-- Honeypot: hidden from people, filled in by simple bots -->
  <div class="form-extra" aria-hidden="true">
    <label for="website">Leave this field empty</label>
    <input id="website" name="website" type="text" tabindex="-1" autocomplete="off">
  </div>

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

Four details keep it from hurting real visitors:

  • Hide it with CSS, not type="hidden". Bots leave hidden inputs alone, because that's where sites put tokens that must go back unchanged, so a type="hidden" honeypot never catches anything.
  • tabindex="-1" keeps keyboard users from tabbing into it.
  • aria-hidden="true" on the wrapper keeps screen readers from announcing it. A focusable element inside an aria-hidden container is an accessibility error, which tabindex="-1" also fixes.
  • autocomplete="off" and a name autofill doesn't recognize stop browsers and password managers filling it for a real visitor. Avoid names like email2, phone, or address; if a browser keeps suggesting values, MDN's fix is to change the name.

The label is for anyone who sees the field anyway, say because your CSS failed to load.

The CSS

.form-extra {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

Give the class a dull name. .honeypot or .hidden tells a bot exactly which field to skip.

Why display: none often fails

style="display:none" is the quickest way to hide the field and the easiest to spot: a script can find it in the raw HTML with one pattern match. Automation tools such as Playwright also count display:none and zero-size elements as not visible, so a bot that fills only visible fields skips the trap. A field moved off-screen keeps a real size and passes that test. A bot that checks positions against the viewport still finds it, so a honeypot is a first layer, never the whole defense.

The server-side check

// Express. The honeypot is the field named "website".
// saveSubmission and notifyTeam are your own functions.
app.post("/contact", express.urlencoded({ extended: false }), async (req, res) => {
  const trapped = String(req.body.website ?? "").trim() !== "";

  await saveSubmission(req.body, trapped
    ? { label: "bot", reason: "Hidden field filled in" }
    : { label: "unscreened" });
  if (!trapped) await notifyTeam(req.body);

  // Same response either way, so the bot learns nothing.
  res.redirect(303, "/thanks");
});

The bot gets the same thank-you page as a person, so it can't tell the trap worked. The submission is kept with a label, so if a password manager ever fills the field for a real visitor, the message isn't lost.

A honeypot misses bots that check visibility, and every person who types a pitch by hand.

2. Set a minimum fill time

People take a while to fill in a form. Scripts take milliseconds. A time trap records when the form was served and flags submissions that come back too fast. A plain timestamp can be edited by the bot, so sign it on the server:

import crypto from "node:crypto";

const SECRET = process.env.FORM_SECRET;
const MIN_MS = 3_000;              // faster than 3 seconds: a script
const MAX_MS = 2 * 60 * 60 * 1000; // older than 2 hours: stale or reused

function sign(value) {
  return crypto.createHmac("sha256", SECRET).update(value).digest("base64url");
}

export function issueFormToken() {
  const issuedAt = String(Date.now());
  return `${issuedAt}.${sign(issuedAt)}`;
}

export function checkFormToken(token) {
  const [issuedAt, signature] = String(token ?? "").split(".");
  if (!issuedAt || !signature) return "missing";
  const given = Buffer.from(signature);
  const expected = Buffer.from(sign(issuedAt));
  if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
    return "forged";
  }
  const age = Date.now() - Number(issuedAt);
  if (age < MIN_MS) return "too-fast";
  if (age > MAX_MS) return "expired";
  return "ok";
}

Render it as <input type="hidden" name="_ts" value="..."> (a hidden input is right for a token) and call checkFormToken(req.body._ts) when the form comes back.

Keep the minimum short: autofill can complete a name-and-email form in a couple of seconds, so a threshold much above 3 seconds catches real people. A bot that loads the page and waits will pass; rate limits make that slow at scale.

3. Require JavaScript to get a token

Many spam bots never load your page. They post straight to the form's URL. A token that only JavaScript can fetch stops all of them, and suits static sites where the server can't write a token into the HTML:

<input type="hidden" name="_ts" value="">
<script>
  const form = document.getElementById("contact");
  form.addEventListener("focusin", async () => {
    const res = await fetch("/form-token"); // returns issueFormToken()
    form.elements._ts.value = await res.text();
  }, { once: true });
</script>

Fetching it when the visitor first enters the form also makes the time trap measure fill time, not time on the page. Check _ts on the server as in step 2. Hold submissions without a token for review, since a visitor with JavaScript off lands there too. Headless browsers run your JavaScript like anyone else, so this stops cheap bots only.

4. Validate everything on the server

required and type="email" only apply to people using your page. A bot posting directly skips them, so repeat every rule on the server and add a few the browser can't:

  • Accept only POST. Answer anything else with 405.
  • Expect your fields. Flag missing ones, and unknown ones: bots written for other forms often post fields yours never had.
  • Cap lengths and check types. A 200-character name, a 20,000-character message, or a phone number made of letters isn't from a buyer.
  • Check the Origin header. Browsers add it to form POST requests, so a request without one, or naming another site, didn't come from your page. (With Referrer-Policy: no-referrer, browsers may send Origin: null, so allow that.)

5. Rate limit by IP address

One IP address sending ten contact requests in a minute is a script. Count submissions per IP over a window:

const recent = new Map(); // ip -> timestamps of recent submissions

function overLimit(ip, limit = 5, windowMs = 10 * 60 * 1000) {
  const now = Date.now();
  const hits = (recent.get(ip) ?? []).filter((t) => now - t < windowMs);
  hits.push(now);
  recent.set(ip, hits);
  return hits.length > limit;
}

This version suits a single server. With more than one, use a shared store such as Redis, or your web server's rate limiting, like nginx's limit_req (rate=5r/m). Behind a proxy or CDN, read the visitor's IP from the header it sets.

Offices and mobile carriers put many people behind one IP, so a strict limit can lock out a real team. Hold over-limit submissions for review, and keep hard 429 responses for floods.

6. Check the email address

Bots and time-wasters often give an address that can't receive mail. Three server-side checks catch most:

  1. Syntax. One @, a domain with a dot, no spaces.
  2. A mail server. Look up the domain's MX records. A domain that publishes a null MX (MX 0 .) is saying it accepts no mail.
  3. Disposable domains. Compare the domain against a list of throwaway inbox providers, such as the community-maintained disposable-email-domains list.
import { resolveMx } from "node:dns/promises";

// disposable: a Set of domains loaded from disposable_email_blocklist.conf
export async function checkEmail(email, disposable) {
  const domain = email.split("@").pop().toLowerCase();
  if (disposable.has(domain)) return "disposable";
  try {
    const records = await resolveMx(domain);
    const nullMx = records.length > 0 && records.every((r) => r.exchange === "" || r.exchange === ".");
    return nullMx ? "no-mail" : "ok";
  } catch {
    return "no-mx"; // no MX records, or the domain doesn't exist
  }
}

Treat no-mx as a reason to review, not reject: a domain with no MX record can still receive mail at its main address. To test addresses by hand, use the free disposable email checker or fake email checker.

Email checks miss a real address typed in by someone else, and a salesperson using their genuine work inbox.

7. Filter the message content

A lot of spam gives itself away in the text:

const links = (message.match(/https?:\/\/|www\./gi) ?? []).length;
const flags = [];
if (links > 2) flags.push("Several links");
if (/https?:\/\/|www\./i.test(name)) flags.push("Link in the name field");
if (/\b(backlinks?|guest post|seo services)\b/i.test(message)) flags.push("SEO pitch wording");

Keyword lists are where content filters break down. Spammers reword, a pitch written with an AI tool has no telltale words, and every keyword you add risks catching a customer who mentions SEO. Hosted services such as Akismet check text against a shared spam network instead of your own list; commercial sites need a paid plan (checked October 2026).

8. Use an invisible challenge (it's still a CAPTCHA)

Cloudflare Turnstile and reCAPTCHA v3 show most visitors no puzzle, so they come up in every "no CAPTCHA" search. They're still CAPTCHAs underneath: a third-party script runs challenges in the browser and hands your form a token for your server to verify.

  • Turnstile has three widget modes. Managed, the recommended one, asks some visitors to tick a checkbox. Non-interactive shows a spinner but never asks for a click. Invisible shows nothing. It's free for up to 20 widgets with unlimited challenges (checked October 2026). Tokens last 300 seconds, verify once, and protect nothing until your server checks them.
  • reCAPTCHA v3 never shows a challenge. It returns a score from 0.0 to 1.0 and you choose the cut-off; Google suggests 0.5. Since April 2026 reCAPTCHA is part of Google Cloud Fraud Defense, and existing keys keep working. More in reCAPTCHA v2 vs v3.

Both judge the browser, not the message, so a person sending a pitch passes. Compare them in Turnstile vs reCAPTCHA, or see the wider field of reCAPTCHA alternatives.

9. Screen how the form was filled in

Behavior screening looks at how a submission was typed, which is much harder to fake than one field or one timestamp:

  • Keystrokes against characters. Text that appears with no key presses and no paste was set by a script.
  • Typing rhythm. People type unevenly. Perfectly even gaps between keys come from a machine.
  • Pastes. A message pasted in whole is typical of pitches sent to hundreds of forms.
  • Pointer or touch. Most people move a mouse or tap before they submit.
  • Automation flags. navigator.webdriver is true when automation controls the browser, for example headless Chrome.

Weigh these together. Browser signals can be faked by a determined bot, and keyboard-only visitors never touch a mouse. A two-second fill with no keystrokes from a datacenter IP is a bot; any one of those alone might be a person.

This is the part GoodInbound does for you. Its script collects these signals invisibly on your existing form and combines them with network checks (datacenter IPs, VPNs and proxies, bursts from one IP) and the email checks from step 6, on every plan including Free.

10. Read the message

Every method so far asks whether a machine sent the submission. None touches the spam that wastes the most time: people. SEO agencies, dev shops, link sellers, and recruiters fill in contact forms by hand, in real browsers, with real work emails, and pass every check above.

The only no-CAPTCHA method that stops them is reading what they wrote and deciding whether it's a genuine enquiry about what you sell. Keyword filters can't: "We help SaaS teams like yours double their demo bookings" contains no spam word.

GoodInbound's message check reads the whole message against what your business sells and catches sales pitches, recruiting, link building, and off-topic messages. A pitch is labeled spam with a one-line reason, such as "Offers outsourced development services". The message check is part of Pro, $29 a month for 2,500 screened submissions. Paste a real example into the free form spam checker to see its label, or read how to stop sales pitches through your contact form.

Don't throw away what you catch

Every check above will sometimes be wrong. A buyer using autofill finishes in two seconds; a team behind one office IP trips the rate limit. Reject those with a 403 and nobody learns they existed, while the error tells a bot which rule it broke. Instead:

  1. Answer every submission the same way, with your normal thank-you page, so bots learn nothing.
  2. Keep everything, with a label and a reason, and check the borderline ones every few days.
  3. Notify only for real leads. Your inbox stays quiet, and nothing is deleted.
  4. Hold auto-replies until a submission is screened. Bots type other people's addresses into forms, so an instant confirmation emails strangers on a bot's behalf, and the complaints count against your domain's sending reputation.

GoodInbound works this way by default: five labels (lead, review, spam, junk, bot), a reason on each, one-click rescue, and notifications per label.

Which methods to combine

Method Catches Misses
Honeypot Simple bots that fill every field Bots that check visibility, people
Signed time trap Scripts that submit instantly Bots that wait, people
JavaScript token Bots that post without loading the page Headless browsers, people
Server validation Malformed and direct-post requests Anything well-formed
Rate limit per IP Floods from one address Bots that rotate IPs, people
Email checks Disposable and dead addresses Pitches from real work inboxes
Content filters Link spam, obvious keywords Reworded and AI-written pitches
Turnstile or reCAPTCHA v3 Most automated traffic People (and Turnstile's managed mode can show a checkbox)
Behavior screening Scripted and automated fills People sending pitches
Reading the message Sales pitches, recruiting, off-topic messages Bots with plausible text, so pair it with the checks above

For a personal site with light spam, a honeypot, a signed time trap, and server validation are enough. A business site with daily bot spam should add the token, rate limits, and email checks, or behavior screening. Lead forms getting pitches need the message read.

Or run every layer with one script

Building all of this takes a few days, then upkeep as bots adapt. GoodInbound Lead Screen covers the same ground (behavior, network, and email checks, plus the message check) behind one script tag on the form you already have: HubSpot, Marketo, Webflow, WordPress, Framer, or plain HTML. You can also use its hosted forms or headless form endpoint, or call the Lead Screen API from your server.

The free plan screens 500 submissions a month with rules, behavior, network, and identity checks. Pro adds the message check; see pricing. For CAPTCHAs, WordPress plugins, and the rest, see the full guide to stopping contact form spam.

FAQ

How do I prevent contact form spam without a CAPTCHA?

Stack checks the visitor never sees: a honeypot, a signed minimum fill time, a JavaScript-only token, per-IP rate limits, and server-side checks on the email and the message. Bots fail the first four. People sending sales pitches only get caught by a check that reads the message.

Do honeypot fields still work, and are they accessible?

They still catch simple bots that fill every field, but bots in a real browser can check visibility and skip them, so use one as a first layer. Built carefully, they're invisible to everyone: hide the field with CSS, put aria-hidden="true" on its wrapper, and give the input tabindex="-1" and autocomplete="off".

Is Cloudflare Turnstile a CAPTCHA?

Yes, though most visitors never see a puzzle. It runs challenges in the browser and gives your form a token to verify, and in managed mode some visitors are asked to tick a checkbox. A person sending a pitch passes it.

Can timing checks and rate limiting stop spam bots?

They stop scripts that submit within a few seconds and bots sending many submissions from one IP. Bots that wait and rotate IPs get through, and strict limits can catch real people on shared networks, so flag rather than block.

Can contact form spam hurt my email deliverability?

Yes, if your form sends an auto-reply. Bots enter other people's addresses, so every instant confirmation goes to someone who never asked for it, and their spam complaints count against your domain. Screen submissions first and only reply to real ones.

How do I stop sales pitches through my contact form?

Read the message. Pitches come from people with real browsers and real work emails, so bot checks and CAPTCHAs let them through. GoodInbound's message check, part of Pro, labels them spam with a one-line reason.

Switch in 5 minutes

Setup with your agents