Why Contact Form Spam Happens and How to Stop It
Contact form spam is one of the most common problems website owners face. Automated bots scan the internet constantly, looking for forms to fill with promotional links, phishing content, or junk messages. For a small business website, receiving dozens of spam submissions per day is not unusual. The messages clutter your inbox, waste your time, and may contain harmful links that your team should not click.
Most website owners first try to solve this with a CAPTCHA. While CAPTCHAs can reduce spam, they also create friction for real visitors. Users with visual impairments may struggle to read distorted text. Parents holding a child cannot easily prove they are human. On mobile devices, solving a puzzle before sending a simple message frustrates people enough to leave the page entirely. Every step between a potential customer and your inbox costs you business.
The good news is that server-side techniques can stop most automated spam without imposing any burden on legitimate visitors. This guide covers practical methods you can implement directly in PHP, starting with the simplest checks and building toward a layered defense strategy.
The Honeypot Field Technique
A honeypot field is a hidden form input that real users cannot see or interact with, but bots automatically fill because they parse every field in the HTML. The concept is straightforward: if a hidden field has a value when the form is submitted, the submission came from a bot, not a person.
The implementation uses CSS to hide the field from browsers while keeping it visible to bots that read the raw HTML source.
<form method="POST" action="/contact">
<input type="text" name="name" required>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<!-- Honeypot: hidden from users, visible to bots -->
<input type="text" name="website" style="display:none" tabindex="-1" autocomplete="off">
<button type="submit">Send</button>
</form>
Choosing the right field name matters. Bots are programmed to fill fields that look like legitimate data, such as website, url, or homepage. A field named dont_fill_this will be ignored. Use display:none or position:absolute; left:-9999px to hide the field visually. Setting tabindex="-1" prevents keyboard users from reaching the field, and autocomplete="off" stops password managers from attempting to fill it.
On the server side, check whether the honeypot field contains any value. If it does, silently reject the submission without alerting the bot.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Honeypot check
if (!empty($_POST['website'])) {
// Bot detected - silently accept to fool the bot
http_response_code(200);
echo "Thank you for your message.";
exit;
}
// Continue processing legitimate submission
}
Silently accepting the submission is important. If the bot receives an error response, it knows the honeypot exists and may try to adapt. By responding as if the submission succeeded, you waste the bot's resources without revealing your defenses.
Timing-Based Bot Detection
Automated bots complete form submissions in milliseconds. A human reading a page, thinking about what to write, and typing a message takes considerably longer. By measuring the time between when the page loaded and when the form was submitted, you can identify submissions that are too fast to be human.
Store a timestamp when the page containing the form is loaded.
session_start();
$_SESSION['form_load_time'] = time();
Use JavaScript with sessionStorage to set the timestamp dynamically, which works better when pages are served from cache.
<script>
sessionStorage.setItem('form_load_time', Math.floor(Date.now() / 1000));
</script>
Send this timestamp with the form submission as a hidden field.
<input type="hidden" name="form_timestamp" id="form_timestamp">
<script>
document.getElementById('form_timestamp').value = sessionStorage.getItem('form_load_time');
</script>
On the server, calculate the elapsed time and reject submissions that arrive too quickly.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$form_load_time = filter_input(INPUT_POST, 'form_timestamp', FILTER_VALIDATE_INT);
$submission_time = time();
$time_taken = $submission_time - $form_load_time;
if ($time_taken < 3) {
// Submitted in less than 3 seconds - likely a bot
http_response_code(200);
echo "Thank you for your message.";
exit;
}
// Process legitimate submission
}
A threshold of three seconds catches most bots while allowing real users who type quickly. You can adjust this threshold based on your form length and audience.
Token Validation and CSRF Protection
Bots that use headless browsers can execute JavaScript and bypass timing checks. Adding a cryptographically secure token that must be present and valid on submission stops many of these more sophisticated attacks. A token also protects your form against cross-site request forgery, where a malicious page tricks a user's browser into submitting your form without their knowledge.
Generate a secure token when the form page loads and include it as a hidden field.
<?php
session_start();
if (!isset($_SESSION['form_token'])) {
$_SESSION['form_token'] = bin2hex(random_bytes(32));
}
?>
<form method="POST" action="/contact">
<input type="hidden" name="form_token" value="<?php echo htmlspecialchars($_SESSION['form_token']); ?>">
<!-- other fields -->
</form>
Validate the token on submission and regenerate it afterward to prevent replay attacks.
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$submitted_token = $_POST['form_token'] ?? '';
$session_token = $_SESSION['form_token'] ?? '';
if (!hash_equals($session_token, $submitted_token)) {
http_response_code(403);
exit('Invalid submission');
}
// Regenerate token to prevent replay attacks
$_SESSION['form_token'] = bin2hex(random_bytes(32));
// Continue processing
}
The hash_equals function prevents timing attacks where an attacker attempts to guess the token by measuring response times. For a deeper look at implementing this securely in PHP, see the guide on CSRF protection in PHP that covers token generation, validation, and common pitfalls.
Rate Limiting by IP Address
Some bots work slowly enough to avoid timing detection. Rate limiting tracks how many submissions come from each IP address and blocks those that exceed a reasonable threshold. Legitimate users rarely submit the same form more than once or twice per hour. Bots may attempt dozens of submissions per minute.
A simple file-based rate limiter stores submission timestamps and checks them on each request.
function isRateLimited(string $ip, int $maxSubmissions = 5, int $windowSeconds = 300): bool
{
$file = '/tmp/form_submissions_' . md5($ip) . '.json';
$submissions = [];
if (file_exists($file)) {
$submissions = json_decode(file_get_contents($file), true) ?? [];
}
// Remove timestamps outside the current window
$cutoff = time() - $windowSeconds;
$submissions = array_filter($submissions, fn($t) => $t > $cutoff);
if (count($submissions) >= $maxSubmissions) {
return true;
}
$submissions[] = time();
file_put_contents($file, json_encode($submissions));
return false;
}
Use this function before processing the form to reject excessive submissions.
$client_ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if (isRateLimited($client_ip)) {
http_response_code(429);
echo "Too many submissions. Please try again later.";
exit;
}
For sites with multiple forms, track submissions by both IP address and endpoint. This prevents a bot from staying under the per-form limit by distributing attacks across different contact pages. For server-level protection against brute force attempts, tools like Fail2Ban can monitor logs and automatically block IPs that show suspicious patterns across your entire server.
Content Analysis for Spam Patterns
Even with structural defenses, some bots submit forms with text designed to promote websites or distribute harmful links. Content analysis examines the submitted message for patterns common in spam and flags or rejects submissions that match.
function analyseFormContent(string $name, string $email, string $message): array
{
$issues = [];
// Check for excessive URLs
$link_count = preg_match_all('/https?:\/\//i', $message);
if ($link_count > 3) {
$issues[] = 'Too many URLs in message';
}
// Check for all-caps (unusual in legitimate messages)
$caps_only = preg_replace('/[^A-Z]/', '', $message);
if (strlen($message) > 0) {
$caps_ratio = strlen($caps_only) / strlen($message);
if ($caps_ratio > 0.5) {
$issues[] = 'Excessive capitalisation';
}
}
// Check message length
if (strlen($message) < 10) {
$issues[] = 'Message too short';
}
if (strlen($message) > 10000) {
$issues[] = 'Message too long';
}
// Check for known spam phrases
$spam_phrases = ['click here now', 'buy now', 'limited time offer'];
foreach ($spam_phrases as $phrase) {
if (stripos($message, $phrase) !== false) {
$issues[] = 'Spam phrase detected';
break;
}
}
return $issues;
}
If content analysis returns issues, you have several options. Reject the submission outright, or save it to a review queue and only process submissions that pass all checks. Flagging suspicious submissions for manual review lets you catch edge cases without blocking legitimate messages.
Email Address Validation
The email address is one of the most critical fields to validate. A contact form with no valid email address means you cannot respond to the inquiry. However, bots often submit fake or malformed addresses.
Start with format validation using PHP's built-in filter.
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['error' => 'Invalid email format'];
}
Then check whether the domain can actually receive email by verifying it has valid MX records.
$domain = substr(strstr($email, '@'), 1);
if (!checkdnsrr($domain, 'MX')) {
return ['error' => 'Email domain cannot receive mail'];
}
For contact forms where you need higher confidence in the email address, sending a confirmation email with a unique token is the most reliable approach. The submitter must click a link in the confirmation email before the message is delivered. This adds friction, so only use it when the stakes are high enough to justify the extra step. For standard contact forms, MX validation provides a reasonable balance between security and user experience.
Building a Layered Defense System
No single technique eliminates all spam on its own. Determined attackers can eventually work around any individual defense. The most effective approach combines multiple checks, each of which is easy for humans and difficult for bots. A bot that somehow bypasses the honeypot still triggers the rate limiter. A slow bot that avoids timing detection still fails content analysis.
The order of checks matters for performance. Run the cheapest validations first and save expensive operations for later. Database queries, external API calls, and sending emails should only happen after all defensive checks pass.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 1. Honeypot check (cheapest operation)
if (!empty($_POST['website'])) {
echo "Thank you for your message.";
exit;
}
// 2. Token validation
$submitted_token = $_POST['form_token'] ?? '';
$session_token = $_SESSION['form_token'] ?? '';
if (!hash_equals($session_token, $submitted_token)) {
http_response_code(403);
exit;
}
// 3. Timing check
$form_load_time = filter_input(INPUT_POST, 'form_timestamp', FILTER_VALIDATE_INT);
if ($form_load_time && (time() - $form_load_time) < 3) {
echo "Thank you for your message.";
exit;
}
// 4. Rate limiting
$client_ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
if (isRateLimited($client_ip)) {
http_response_code(429);
exit;
}
// 5. Content analysis
$issues = analyseFormContent($name, $email, $message);
if (!empty($issues)) {
// Flag for review instead of rejecting
}
// 6. Validate email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
// Return error to user
}
// 7. Send email (most expensive operation)
// ...
}
Layering these techniques stops virtually all automated spam while keeping your form accessible to legitimate visitors. Only in rare high-risk scenarios, such as forms that access sensitive data or trigger financial transactions, should you consider adding a CAPTCHA as an additional measure.
When to Consider Server-Level Protection
Application-level techniques protect your forms, but bots that target your site may also probe other entry points. A web application firewall or intrusion prevention system can detect and block malicious traffic across your entire server before it reaches your application code.
Tools like Fail2Ban monitor server logs for patterns that indicate automated attacks, such as repeated failed login attempts or rapid requests to specific URLs. When a pattern is detected, the tool automatically updates firewall rules to block the offending IP. This reduces the load on your application and provides protection for services beyond your contact form. For a practical setup guide, see the article on Fail2Ban SSH and HTTP protection.