Anti-Robot Contact Form

An analysis of layered anti-bot techniques for PHP contact forms — how they work, what they stop, and where they fall short.

CAPTCHA was purposely left out of this implementation so each layer could be studied on its own merits.

Always use CAPTCHA when it is available. Nothing here replaces a proper challenge service. These mechanisms are useful as additional layers, not as a substitute.

The Problem

Public contact forms are easy targets. A bot can POST directly to your handler, fill every visible field with garbage, and spam your inbox hundreds of times per hour. Worse, if user input is copied into mail headers without cleaning, attackers can inject extra headers or abuse your server as a spam relay.

This project hardens a real contact form for See It All Home Inspection using several lightweight defenses that work together.

The Approach

Think of anti-bot protection as layers. Each layer stops a different class of attacker. CAPTCHA sits at the top and blocks human-vs-machine challenges. Below that, honeypots, tokens, timing checks, POST-only handlers, sanitization, and rate limits raise the cost of automation without bothering real users.

Flow at a glance

Browser loads contact page

JS fetches one-time token from token.php

User fills form (must wait ≥ 3 seconds)

POST to submit.php

Rate limit → Sanitize → Honeypot → Token → Timing → Send mail

Client + Server

1. Honeypot Field

A honeypot is a field hidden from humans but still present in the HTML. Bots often auto-fill every input they find. Humans never see it, so it should always be empty.

Naming matters. The honeypot should use a name bots expect and want to fill — something obvious like email. The real email field should use a less predictable name like client-email or sender. Bots gravitate toward standard field names; humans follow the visible label and placeholder, not the name attribute.

HTML

<!-- Real field: visible, less obvious name -->
<input type="email" name="client-email" placeholder="Email" required>

<!-- Honeypot: hidden, enticing name bots will auto-fill -->
<input style="display: none" type="email" name="email">

In this project the real field is named sender and the honeypot keeps the bait name email.

Server check

$email    = form_sanitize_email($_POST['client-email'] ?? '');  // real field
$botEmail = form_sanitize_text($_POST['email'] ?? '', 254);       // honeypot

if ($botEmail !== '') {
    $isComplete = false;
}
Stops Dumb scrapers that blast every field on a form.
Doesn't stop Bots that skip display:none fields or replay a real submission.
Client + Server

2. JavaScript Token (HMAC)

Instead of a static hidden value bots can copy from page source, the form fetches a fresh token when the page loads. The token is an HMAC of a server timestamp and a secret only PHP knows. Bots that POST directly to submit.php without loading the page never receive a valid pair.

Token endpoint — token.php

$ts = time();
echo json_encode([
    'ts'    => $ts,
    'token' => hash_hmac('sha256', (string) $ts, FORM_SECRET),
]);

Browser — on page load

fetch("token.php")
    .then(response => response.json())
    .then(data => {
        document.getElementById("form_ts").value = data.ts;
        document.getElementById("form_token").value = data.token;
    });

Server verification

function form_verify_token($ts, $token) {
    $ts  = (int) $ts;
    $now = time();

    if ($ts > $now)      return false;  // future timestamp
    if (($now - $ts) < MIN_SUBMIT_DELAY) return false;  // too fast
    if (($now - $ts) > MAX_TOKEN_AGE)   return false;  // expired

    return hash_equals(form_make_token($ts), $token);
}
Stops Scripts that hit the submit URL cold without executing JS or calling the token endpoint.
Doesn't stop Headless browsers that load the full page and run JavaScript.
Client + Server

3. Submit Delay (Timing Check)

Real users need time to read and type. Bots often submit instantly. This project enforces a minimum 3-second delay both in the browser (UX) and on the server (security).

Client-side

contactForm.addEventListener("submit", function (event) {
    const elapsed = Date.now() - formReadyAt;
    if (!formReadyAt || elapsed < 3000) {
        event.preventDefault();
        alert("Please wait a moment before submitting.");
    }
});

Server-side

The token timestamp must be at least MIN_SUBMIT_DELAY seconds old before submit.php accepts it. This is built into form_verify_token above — any token younger than 3 seconds is rejected.

Stops Fire-and-forget bots that submit the moment the HTML is parsed.
Doesn't stop Bots that sleep for a few seconds before posting.
Server

4. POST Instead of GET

The original form used the browser default (GET), which puts names, emails, and messages in the URL. That is bad for privacy and makes scripted abuse trivial.

Before

<form action="submit.php">  <!-- defaults to GET -->

After

<form method="post" action="submit.php">

Server enforcement

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    form_reject_and_redirect();
}

Data is read from $_POST instead of $_GET. URLs are no longer logged with customer PII, and casual link-based abuse is harder.

Server

5. Input Sanitization

Anti-bot is not only about bots. Any input can be malformed or malicious. Sanitization validates shape, strips dangerous characters, and caps length before values touch mail().

Sanitization helpers

function form_sanitize_email($value) {
    $value = form_sanitize_text($value, 254);
    $email = filter_var($value, FILTER_VALIDATE_EMAIL);
    return $email !== false ? $email : '';
}

function form_sanitize_text($value, $maxLen = 200) {
    $value = trim((string) $value);
    $value = str_replace(["\r", "\n", "\0"], '', $value);
    return substr($value, 0, $maxLen);
}

Mail header safety

The visitor's email goes in Reply-To, not From, so newline injection into headers is much harder.

$headers = "From: noreply@seeitallinspection.com\r\nReply-To: $safeFrom\r\n";
Stops Header injection, absurdly long payloads, and garbage that could crash the handler.
Doesn't stop Well-formed spam that passes validation — that is what CAPTCHA and rate limits handle.
Server

6. Rate Limiting (Per IP)

Even if every other check passes, one attacker could still send mail in a loop. File-based rate limiting tracks recent attempts per IP and blocks further POSTs for an hour after the limit is hit.

Configuration — 3 submissions per IP per hour

define('RATE_LIMIT_MAX',    3);
define('RATE_LIMIT_WINDOW', 3600);

$file = RATE_LIMIT_DIR . '/' . hash('sha256', $ip) . '.json';

if (count($attempts) >= RATE_LIMIT_MAX) {
    return true;  // blocked
}

Attempt timestamps are stored in .rate_limit/ with web access denied via .htaccess. Both failed and successful POSTs count, so bots cannot probe validation forever without hitting the cap.

Stops High-volume spam from a single IP address.
Doesn't stop Distributed attacks from many IPs — that is where CAPTCHA helps most.
Not implemented

7. CAPTCHA (Use When You Can)

CAPTCHA services ask the client to prove it is likely human before the server accepts the submission. Cloudflare Turnstile and Google reCAPTCHA are the most common choices.

This project intentionally omits CAPTCHA so the other techniques stand on their own for learning and demonstration. In production, add CAPTCHA first, then keep honeypots, tokens, timing, POST, sanitization, and rate limits as defense in depth.

Example (Cloudflare Turnstile)

<!-- Example only — not in this repo -->
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>

Server-side you verify the token with the provider API before sending mail. No amount of honeypot or timing logic matches CAPTCHA against modern bot farms.


File Layout

Contact/
    index.html — form + JS token fetch + submit delay
    token.php — issues { ts, token } JSON
    submit.php — POST handler, all checks, mail()
    config.php — secret, limits, sanitize + verify helpers
    .rate_limit/ — IP attempt logs (not web-accessible)

Summary

Each layer targets a different weakness. Honeypots catch naive fillers. JS tokens and timing catch direct POST scripts. POST + sanitization harden transport and output. Rate limits cap damage from one source.

Used together, these layers turn a wide-open mail() endpoint into something that stops casual bot traffic without adding friction for real customers. For anything facing the open internet long term, treat this stack as a baseline and add CAPTCHA on top.