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.
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.
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.
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.
<!-- 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.
$email = form_sanitize_email($_POST['client-email'] ?? ''); // real field
$botEmail = form_sanitize_text($_POST['email'] ?? '', 254); // honeypot
if ($botEmail !== '') {
$isComplete = false;
}
display:none fields or replay a real submission.
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.
$ts = time();
echo json_encode([
'ts' => $ts,
'token' => hash_hmac('sha256', (string) $ts, FORM_SECRET),
]);
fetch("token.php")
.then(response => response.json())
.then(data => {
document.getElementById("form_ts").value = data.ts;
document.getElementById("form_token").value = data.token;
});
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);
}
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).
contactForm.addEventListener("submit", function (event) {
const elapsed = Date.now() - formReadyAt;
if (!formReadyAt || elapsed < 3000) {
event.preventDefault();
alert("Please wait a moment before submitting.");
}
});
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.
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.
<form action="submit.php"> <!-- defaults to GET -->
<form method="post" action="submit.php">
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.
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().
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);
}
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";
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.
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.
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 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.
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.