Home Work Approach Blog Contact
All articles

A Layered Way to Stop Contact Form Spam (No CAPTCHA)

How I block most spam without ever showing a CAPTCHA: honeypots, a time threshold, IP-based rate limiting, and server-side validation.

A Layered Way to Stop Contact Form Spam (No CAPTCHA)

A few days after a contact form goes live, the bot traffic starts. The first instinct is to slap a CAPTCHA on it — but a CAPTCHA has a cost: some share of your real customers will bail on that puzzle. If the form doesn't get many submissions to begin with, blocking spam ends up blocking customers too.

The layered approach below cuts most of the spam without adding any friction for the user. It's what I use on this site's own contact form.

Layer 1 — the honeypot

Simple and surprisingly effective. Add a field to the form that humans can't see. Most bots parse the form and fill in every field. A field that comes back filled in tells you the sender is a bot.

Critical detail: don't hide the field with type="hidden" — bots recognize that. Push it off-screen with CSS and skip it for screen readers instead:

<div style="position:absolute; left:-9999px;" aria-hidden="true">
  <label>Your website</label>
  <input type="text" name="website" tabindex="-1" autocomplete="off">
</div>

One line of server-side logic:

if (!empty($_POST['website'])) {
    // Pretend it succeeded — don't tip off the bot
    header("Location: /contact.php?status=success");
    exit;
}

Note: don't tell the bot "caught you." If you behave as if it succeeded, the spam tool assumes the form worked and won't bother changing its strategy.

Layer 2 — a time threshold

A human can't fill out a contact form in 2 seconds. A bot can. Carry the timestamp of when the form was rendered in a hidden field and check the difference on submit:

$elapsed = time() - (int)($_POST['form_ts'] ?? 0);
if ($elapsed < 3 || $elapsed > 3600) {
    // Too fast: a bot. Too slow: a stale session.
    exit;
}

Sign the timestamp (with hash_hmac) — otherwise a bot just generates the value itself.

Layer 3 — IP-based rate limiting

Ten form submissions an hour from the same IP isn't normal human behavior. Log the IP and timestamp on every submission, and reject anyone who exceeds the window you set.

A JSON file is enough for small sites; move to a database or Redis once traffic grows. Two warnings:

  • Concurrent writes: use a lock (LOCK_EX) when writing to the file, or two requests can overwrite each other's record.
  • Shared IPs: users behind the same office network or mobile carrier can share an IP. Don't set the limit too aggressively — 3-5 submissions an hour is a reasonable start.
  • Keep the log file outside the web root. If you're keeping it inside the document root, block access via .htaccess — it contains visitor IPs, and that's personal data.

That log file grows over time and quietly bloats if nobody's watching it. Add regular cleanup to your maintenance routine as a line item.

Layer 4 — server-side validation

The required and type="email" attributes in your markup are for user experience, not security. A bot doesn't use a browser — it hits the server directly. Re-validate every field server-side:

  • Is the email actually valid format? (filter_var($mail, FILTER_VALIDATE_EMAIL))
  • Do fields have a length limit? A thousand-character "name" field is suspicious.
  • Is the number of links in the message reasonable? Three or more links is a classic spam signature.
  • Do fields contain a line-break character (\r\n)? That's an email header injection attempt — always strip it.

What you tell the user when validation fails also matters. Rather than "something went wrong," show the problem and the fix — a principle I go into further in the admin panel post.

Layer 5 — CAPTCHA, but the invisible kind, if you still need it

The four layers above are enough for most sites. If you're still getting targeted despite that, add a CAPTCHA — but choose the invisible version that runs in the background rather than making the user solve a puzzle. It adds a layer without breaking the experience.

A suggested order

You don't need to build all of this at once. Add layers in sequence and measure spam at each step: the honeypot alone usually causes a big drop, the time threshold catches most of what's left. You may not even need the third layer.

Adding layers without measuring means unnecessary complexity and the risk of blocking a real customer. The form's actual job isn't to stop spam — it's to get the customer to you.

More articles