We’ve all experienced the frustration: submitting a contact form and never receiving a reply, or worse, watching our own website’s inbox flood with obvious spam. Whether it’s bots bypassing weak validation, poor email configuration sending legitimate messages straight to spam folders, or forms that simply break under real-world usage — most contact forms are either ineffective or create more problems than they solve.
This guide covers building forms that genuinely work: effective spam protection, robust server-side validation, and proper email handling that ensures messages reach their destination. Every code example here is production-ready and tested — not pseudo-code dressed up to look clever.
What You’ll Learn
- How to implement Google reCAPTCHA v3 effectively with appropriate scoring
- Why server-side validation is essential and how to do it properly
- Building accessible AJAX forms with graceful degradation
- Practical spam prevention techniques beyond just CAPTCHA
- Email header configuration that prevents legitimate messages from being flagged
- File-based rate limiting that actually works (unlike session-based approaches)
The Contact Form Problem
Most contact form failures fall into predictable categories:
Security gaps that let spam flood your inbox whilst blocking legitimate users with overly aggressive filters.
Poor email configuration that sends your carefully crafted responses directly to spam folders.
Client-side-only validation that provides no actual protection against malicious input.
Accessibility issues that make forms unusable for people with disabilities or older browsers.
No fallback handling when JavaScript fails or network requests time out.
The solution isn’t more complex technology — it’s implementing the fundamentals correctly.
Google reCAPTCHA v3: Beyond the Basics
reCAPTCHA v3 works by analysing user behaviour and assigning a score between 0.0 (almost certainly a bot) and 1.0 (almost certainly human). Google recommends starting with a threshold of 0.5, but this requires careful adjustment based on your actual traffic patterns.
Implementation Strategy
The key is a progressive response rather than binary blocking. The following is a conceptual illustration of the scoring logic — in real implementation this lives in your PHP after verifying the token server-side:
// Conceptual scoring logic — implement in PHP after server-side token verification
if ($recaptcha_score >= 0.7) {
// High confidence human — process immediately
processForm();
} elseif ($recaptcha_score >= 0.3) {
// Medium confidence — you might add extra checks here,
// but reCAPTCHA v3 has no built-in "step up" challenge.
// Log and proceed with caution, or silently reject.
logAndProceedWithCaution();
} else {
// Low confidence — silent rejection with logging
logSuspiciousActivity();
showGenericErrorMessage(); // Never tell bots WHY they failed
}
Important note on “medium confidence” handling: reCAPTCHA v3 does not offer a built-in step-up challenge (unlike v2’s checkbox). If you want to challenge medium-confidence users, you would need to implement reCAPTCHA v2 as a fallback, which adds significant complexity. For most contact forms, simply rejecting scores below 0.5 is the practical and correct approach.
Threshold Considerations
Real-world experience shows that thresholds need regular adjustment. Setting too high (above 0.7) risks blocking legitimate users with unusual browsing patterns, whilst too low (below 0.3) allows sophisticated spam through.
Best practice: Start with 0.5, monitor your reCAPTCHA admin console for 2–4 weeks, then adjust based on actual traffic patterns.
Proper Integration
The reCAPTCHA token is generated in JavaScript and then verified server-side in PHP. The token expires after two minutes, so it must be generated fresh on submit — not on page load. Here’s the correct pattern:
JavaScript — generate a fresh token on form submit:
// Generate a fresh reCAPTCHA token at the moment of submission.
// Do NOT generate it on page load — tokens expire after 2 minutes.
grecaptcha.ready(function () {
document.getElementById('contact-form').addEventListener('submit', function (e) {
e.preventDefault();
grecaptcha.execute('YOUR_SITE_KEY', {action: 'contact_form'})
.then(function (token) {
document.getElementById('recaptcha-token').value = token;
submitForm(); // Your actual form submission function
});
});
});
PHP — server-side token verification using cURL:
// Use cURL rather than file_get_contents — more reliable and works
// regardless of whether allow_url_fopen is enabled on your host.
function verifyRecaptcha(string $token, string $secret_key, string $ip): array
{
$ch = curl_init('https://www.google.com/recaptcha/api/siteverify');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => $secret_key,
'response' => $token,
'remoteip' => $ip,
]),
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$curl_error = curl_error($ch);
curl_close($ch);
if ($response === false) {
// cURL failed — log it, fail safely
error_log('reCAPTCHA cURL error: ' . $curl_error);
return ['success' => false, 'score' => 0.0];
}
$result = json_decode($response, true);
if (!is_array($result)) {
error_log('reCAPTCHA returned invalid JSON');
return ['success' => false, 'score' => 0.0];
}
return $result;
}
// Usage:
$recaptcha = verifyRecaptcha(
$_POST['recaptcha_token'] ?? '',
$RECAPTCHA_SECRET,
$_SERVER['REMOTE_ADDR']
);
if (!$recaptcha['success'] || ($recaptcha['score'] ?? 0) < 0.5) {
error_log('reCAPTCHA failed. Score: ' . ($recaptcha['score'] ?? 'n/a') . ' IP: ' . $_SERVER['REMOTE_ADDR']);
throw new Exception('Security validation failed. Please try again.');
}
Why cURL and not file_get_contents? Many shared hosting environments have allow_url_fopen disabled as a security measure. file_get_contents on a remote URL silently fails in that case, returning false — which then gets passed to json_decode, returns null, and your verification check either throws an error or, worse, passes through unchecked. cURL works regardless of that setting and gives you proper error handling and timeout control.
Server-Side Validation: The Non-Negotiable Foundation
Client-side validation improves user experience but provides zero security. Every input must be validated on the server, regardless of what JavaScript may have already checked. A user (or bot) can submit a form without ever touching your JavaScript.
Essential Validation Functions
// Email validation with multiple checks
function validateEmail(string $email): bool
{
// PHP's built-in format check is solid for most cases
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return false;
}
// Check the domain actually has mail records
$domain = substr(strrchr($email, '@'), 1);
if (!checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) {
return false;
}
// Block a sample of well-known disposable email domains
// Note: This list needs regular maintenance — there are thousands of them.
// Consider an API service (e.g. Kickbox, Abstract) for comprehensive coverage.
$disposable_domains = ['tempmail.org', '10minutemail.com', 'guerrillamail.com', 'mailnator.com', 'trashmail.com'];
if (in_array(strtolower($domain), $disposable_domains, true)) {
return false;
}
return true;
}
// Input sanitisation — clean before use, validate separately
function sanitizeInput(string $input, int $max_length = 1000): string
{
$input = trim($input);
$input = stripslashes($input);
// htmlspecialchars is for output encoding (preventing XSS when displaying).
// For storing or emailing, trim and strip are sufficient.
// Apply htmlspecialchars at the point of output, not here.
if (mb_strlen($input) > $max_length) {
$input = mb_substr($input, 0, $max_length);
}
return $input;
}
A note on sanitisation vs. validation: These are two different things and should not be conflated. Sanitisation cleans the data (trimming whitespace, removing unwanted characters). Validation checks whether the data is acceptable (correct format, within length limits). Do both, but keep them separate and clear in your code.
A note on disposable email lists: The short list above is illustrative only. There are thousands of disposable email providers and the list changes constantly. For serious coverage, use a dedicated API service rather than maintaining your own list.
Validation Beyond the Obvious
Message length limits: Legitimate contact form enquiries rarely exceed 2,000 characters. Enforce this server-side regardless of the HTML maxlength attribute, which can be trivially bypassed.
Time-based validation: A form submitted in under 3–5 seconds is almost certainly automated. Track the form load time server-side (not client-side, where it can be spoofed) using a session timestamp set when the page is served.
Rate Limiting That Actually Works
Session-based rate limiting — storing counters in $_SESSION — does not work against bots. Sessions rely on cookies. Bots don't send cookies, or simply start a fresh session for every request. You need server-side storage that persists independently of the user's session.
The two practical options without a database are file-based storage or APCu (PHP's in-memory cache). Below is a robust file-based implementation that works on any standard hosting environment:
/**
* File-based rate limiting — works regardless of whether the client sends cookies.
* Stores counters keyed by IP in a JSON file outside the web root.
*
* @param string $ip The visitor's IP address
* @param int $max Maximum allowed attempts in the window
* @param int $window_sec Time window in seconds (e.g. 3600 = 1 hour)
* @return bool true = allowed, false = rate limited
*/
function checkRateLimit(string $ip, int $max = 3, int $window_sec = 3600): bool
{
// Store outside the web root — adjust this path to suit your server layout
$store_file = sys_get_temp_dir() . '/cf_ratelimit.json';
$data = [];
if (file_exists($store_file)) {
$raw = file_get_contents($store_file);
$data = json_decode($raw, true) ?? [];
}
$now = time();
$key = hash('sha256', $ip); // Don't store raw IPs if you can avoid it
// Prune expired entries to keep the file from growing indefinitely
foreach ($data as $k => $entry) {
if ($now - $entry['first_attempt'] >= $window_sec) {
unset($data[$k]);
}
}
if (!isset($data[$key])) {
$data[$key] = ['count' => 0, 'first_attempt' => $now];
}
$data[$key]['count']++;
$data[$key]['last_attempt'] = $now;
// Write back (use locking to prevent race conditions)
file_put_contents($store_file, json_encode($data), LOCK_EX);
return $data[$key]['count'] <= $max;
}
/**
* Set a timestamp in the session when the form page is served.
* Check this on submission to catch forms submitted suspiciously fast.
*
* Call setFormTimestamp() when rendering the page.
* Call checkSubmissionTime() when processing the POST.
*/
function setFormTimestamp(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION['form_served_at'] = time();
}
function checkSubmissionTime(int $min_seconds = 5): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$served_at = $_SESSION['form_served_at'] ?? 0;
return (time() - $served_at) >= $min_seconds;
}
Spam Content Detection
Keyword filtering is a useful supplementary layer but must be calibrated carefully. Setting thresholds too aggressively will block legitimate messages. For example, a woodworking supplier might legitimately receive a message containing "urgent order" — flagging that as spam would be a problem.
function checkSpamContent(string $message, string $subject = ''): int
{
$spam_indicators = [
'high_risk' => ['bitcoin', 'cryptocurrency', 'forex', 'investment opportunity', 'wire transfer'],
'medium_risk' => ['click here', 'limited time', 'act now', 'congratulations', 'you have been selected'],
'patterns' => [
'/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/', // Raw IP addresses in message body
'/https?:\/\/(bit\.ly|tinyurl\.com|t\.co)\/\w+/', // Shortened URLs
'/(.)\1{8,}/', // Repeated characters (aaaaaaaa)
],
];
$risk_score = 0;
$content = mb_strtolower($message . ' ' . $subject);
foreach ($spam_indicators['high_risk'] as $term) {
if (str_contains($content, $term)) {
$risk_score += 3;
}
}
foreach ($spam_indicators['medium_risk'] as $term) {
if (str_contains($content, $term)) {
$risk_score += 2;
}
}
foreach ($spam_indicators['patterns'] as $pattern) {
if (preg_match($pattern, $message)) {
$risk_score += 2;
}
}
return $risk_score;
}
// A score above 5 is suspicious. Adjust based on what you observe in your logs.
// Do not set this so low that a single "urgent" keyword kills legitimate messages.
// Log rejections so you can review and tune the threshold over time.
Honeypot Fields
A honeypot is a form field that is hidden from human users but visible to bots crawling the page's HTML. If it gets filled in, it's almost certainly a bot.
HTML — hide with CSS, not just type="hidden":
<!-- Honeypot: hidden from humans via CSS, but bots reading the HTML will fill it -->
<div style="position: absolute; left: -9999px; top: -9999px;" aria-hidden="true">
<label for="website">Leave this blank</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
Why use CSS positioning rather than display: none? Some sophisticated bots skip fields that are display: none or type="hidden" precisely because they know those are honeypots. Positioning off-screen is less obvious to them. Some screen readers may still encounter it, which is why aria-hidden="true" and tabindex="-1" are both important.
PHP — check server-side:
// Honeypot check — if the field has any content, it's a bot. Reject silently.
if (!empty($_POST['website'])) {
error_log('Honeypot triggered from IP: ' . $_SERVER['REMOTE_ADDR']);
// Return a fake success to avoid telling the bot it was caught
echo json_encode(['success' => true, 'message' => 'Thank you for your message.']);
exit;
}
AJAX Implementation with Accessibility
Modern forms should submit without page refreshes where possible, but must degrade gracefully when JavaScript is unavailable. The form below works as a standard POST without JavaScript and is enhanced by JavaScript when it's available.
HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Us</title>
<!-- reCAPTCHA script: replace YOUR_SITE_KEY with your actual key -->
<script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY" async defer></script>
</head>
<body>
<form method="POST" action="/contact.php" id="contact-form" novalidate>
<div class="form-group">
<label for="name">Name <span aria-hidden="true">*</span></label>
<input type="text" id="name" name="name" required maxlength="100"
autocomplete="name" aria-required="true" aria-describedby="name-error">
<div class="error-message" id="name-error" role="alert" aria-live="polite"></div>
</div>
<div class="form-group">
<label for="email">Email Address <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" required maxlength="254"
autocomplete="email" aria-required="true" aria-describedby="email-error">
<div class="error-message" id="email-error" role="alert" aria-live="polite"></div>
</div>
<div class="form-group">
<label for="subject">Subject <span aria-hidden="true">*</span></label>
<input type="text" id="subject" name="subject" required maxlength="200"
aria-required="true" aria-describedby="subject-error">
<div class="error-message" id="subject-error" role="alert" aria-live="polite"></div>
</div>
<div class="form-group">
<label for="message">Message <span aria-hidden="true">*</span></label>
<textarea id="message" name="message" required maxlength="2000" rows="6"
aria-required="true" aria-describedby="message-error"></textarea>
<div class="error-message" id="message-error" role="alert" aria-live="polite"></div>
</div>
<!-- Honeypot: hidden from humans, visible to bots -->
<div style="position: absolute; left: -9999px; top: -9999px;" aria-hidden="true">
<label for="website">Leave this blank</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<!-- reCAPTCHA token — populated by JavaScript before submission -->
<input type="hidden" id="recaptcha-token" name="recaptcha_token">
<button type="submit" id="submit-btn">Send Message</button>
<!-- aria-live="polite" means screen readers announce this when it changes -->
<div id="form-status" role="alert" aria-live="polite"></div>
</form>
<script>
(function () {
'use strict';
var form = document.getElementById('contact-form');
var submitBtn = document.getElementById('submit-btn');
var statusDiv = document.getElementById('form-status');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
// Disable the button to prevent double submission
submitBtn.disabled = true;
var originalLabel = submitBtn.textContent;
submitBtn.textContent = 'Sending\u2026';
statusDiv.textContent = '';
statusDiv.className = '';
// Generate a fresh reCAPTCHA token at the moment of submission.
// Tokens expire after 2 minutes so this must NOT be done on page load.
grecaptcha.ready(function () {
grecaptcha.execute('YOUR_SITE_KEY', {action: 'contact_form'})
.then(function (token) {
document.getElementById('recaptcha-token').value = token;
fetch('/contact.php', {
method: 'POST',
body: new FormData(form),
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function (response) {
if (!response.ok) {
throw new Error('Server returned ' + response.status);
}
return response.json();
})
.then(function (data) {
statusDiv.textContent = data.message;
statusDiv.className = data.success ? 'form-success' : 'form-error';
if (data.success) {
form.reset();
}
})
.catch(function () {
statusDiv.textContent = 'Sorry, something went wrong. Please try again or contact us directly by email.';
statusDiv.className = 'form-error';
})
.finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = originalLabel;
});
})
.catch(function () {
// reCAPTCHA itself failed (e.g. ad blocker, network issue)
statusDiv.textContent = 'Security check failed. Please disable any ad blockers and try again.';
statusDiv.className = 'form-error';
submitBtn.disabled = false;
submitBtn.textContent = originalLabel;
});
});
});
}());
</script>
</body>
</html>
Accessibility notes:
aria-live="polite"on the status div means screen readers will announce the result after submission without interrupting the user mid-sentence.role="alert"on inline error divs ensures errors are announced immediately.aria-required="true"supplements the HTMLrequiredattribute for older assistive technology.autocompleteattributes help users with motor impairments who rely on browser autofill.- The submit button is re-enabled after the request completes (success or failure) — never leave users stranded with a permanently disabled button.
Email Configuration That Works
Poor email headers are a common reason contact form submissions end up in spam folders. There are two separate concerns here: PHP's mail() function and your domain's DNS authentication records (SPF, DKIM, DMARC).
PHP mail() vs. a Proper Mail Library
PHP's built-in mail() function works, but it delegates everything to your server's local mail transfer agent (MTA — typically Sendmail or Postfix). Whether DKIM signing happens depends entirely on how your MTA is configured, not on anything you do in PHP. If your host hasn't configured DKIM signing at the MTA level, no amount of PHP header manipulation will add it.
If email deliverability is critical, use PHPMailer or SwiftMailer via SMTP to an authenticated mail service (your host's SMTP server, or a transactional provider such as Brevo, Postmark, or Mailgun). This guarantees DKIM signing because the mail service handles it. For a basic contact form on shared hosting, mail() with correct headers is generally adequate.
Correct Header Configuration
function sendContactEmail(
string $to_email,
string $from_email,
string $from_name,
string $subject,
string $message
): bool {
// Validate the submitter's email before doing anything else
if (!filter_var($from_email, FILTER_VALIDATE_EMAIL)) {
return false;
}
// Strip anything non-printable from the name — prevents header injection
$from_name = preg_replace('/[\r\n\t]/', '', $from_name);
$from_name = htmlspecialchars($from_name, ENT_QUOTES, 'UTF-8');
// The From address MUST be on your own domain.
// Using the submitter's email here causes SPF/DMARC failures and spam classification.
// Reply-To is where your replies will go — set that to the submitter's email.
$your_domain = 'yourdomain.com'; // Replace with your actual domain
$noreply = 'noreply@' . $your_domain;
$bounces = 'bounces@' . $your_domain;
$headers = 'From: ' . $noreply . "\r\n";
$headers .= 'Reply-To: ' . $from_email . "\r\n";
$headers .= 'Return-Path: ' . $bounces . "\r\n";
$headers .= 'Message-ID: <' . uniqid('', true) . '@' . $your_domain . ">\r\n";
$headers .= 'Date: ' . date('r') . "\r\n";
$headers .= 'MIME-Version: 1.0' . "\r\n";
$headers .= 'Content-Type: text/plain; charset=UTF-8' . "\r\n";
$headers .= 'Content-Transfer-Encoding: 8bit' . "\r\n";
$headers .= 'X-Mailer: ContactForm/1.0' . "\r\n";
$headers .= 'X-Priority: 3' . "\r\n";
// Sanitise the subject — prevent header injection
$clean_subject = preg_replace('/[\r\n]/', '', $subject);
$body = "New contact form submission\n";
$body .= str_repeat('-', 40) . "\n";
$body .= "From: {$from_name} <{$from_email}>\n";
$body .= "Subject: {$clean_subject}\n\n";
$body .= wordwrap($message, 72, "\n", false) . "\n\n";
$body .= str_repeat('-', 40) . "\n";
$body .= 'Host: ' . ($_SERVER['HTTP_HOST'] ?? 'unknown') . "\n";
$body .= 'IP Address: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . "\n";
$body .= 'Timestamp: ' . date('Y-m-d H:i:s T') . "\n";
return mail($to_email, 'Contact Form: ' . $clean_subject, $body, $headers);
}
Critical Header Rules
Never use the submitter's email in the From header. Your domain's SPF record authorises your server to send mail from your domain. If you put someone else's email address in From, the receiving server checks SPF for their domain, finds your server isn't on it, and either rejects the message or marks it as spam. This is one of the most common contact form mistakes.
Always set Reply-To correctly. This is the address that appears when someone clicks Reply in their email client. Set it to the submitter's email so you can reply directly without copying and pasting.
Sanitise the subject line. User input in email headers is a header injection vector. A malicious user could append \r\nBcc: victim@example.com to turn your contact form into a spam relay. Always strip carriage returns and newlines from any user-supplied value that goes into a header.
DNS Authentication Records
These are configured in your domain's DNS, not in PHP. They are essential for reliable email delivery in 2025 — Gmail and Outlook both use them to filter incoming mail.
SPF — tells receiving mail servers which servers are authorised to send email for your domain. Replace YOUR_SERVER_IP with your actual server IP:
v=spf1 mx ip4:YOUR_SERVER_IP ~all
DKIM — cryptographically signs outgoing emails so the receiving server can verify they haven't been tampered with. This must be configured at the MTA level on your server (or by your hosting provider / mail service). There is no DNS record you can simply copy-paste here — it requires generating a key pair and publishing the public key as a TXT record. Contact your host or refer to their documentation.
DMARC — tells receiving servers what to do when SPF or DKIM checks fail. Start with p=none (monitor only) and move to p=quarantine once you've confirmed your legitimate mail is passing authentication:
v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com
Complete Server-Side Processing
Here is a complete, self-contained contact.php that ties all of the above together. Replace the configuration values at the top with your own.
<?php
declare(strict_types=1);
session_start();
// ─── Configuration ────────────────────────────────────────────────────────────
const RECAPTCHA_SECRET = 'your-recaptcha-secret-key';
const ADMIN_EMAIL = 'contact@yourdomain.com';
const YOUR_DOMAIN = 'yourdomain.com';
const RECAPTCHA_THRESHOLD = 0.5;
const SPAM_SCORE_LIMIT = 6;
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW = 3600; // 1 hour in seconds
const MIN_FORM_TIME = 5; // Minimum seconds before a submission is considered human
// ──────────────────────────────────────────────────────────────────────────────
/**
* Verify the reCAPTCHA token via cURL.
* Returns the full Google response array, or a failed result on error.
*/
function verifyRecaptcha(string $token, string $secret, string $ip): array
{
$ch = curl_init('https://www.google.com/recaptcha/api/siteverify');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'secret' => $secret,
'response' => $token,
'remoteip' => $ip,
]),
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$curl_err = curl_error($ch);
curl_close($ch);
if ($response === false) {
error_log('reCAPTCHA cURL error: ' . $curl_err);
return ['success' => false, 'score' => 0.0];
}
$result = json_decode($response, true);
return is_array($result) ? $result : ['success' => false, 'score' => 0.0];
}
/**
* Validate an email address: format, DNS, and a basic disposable-domain check.
*/
function validateEmail(string $email): bool
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return false;
}
$domain = substr(strrchr($email, '@'), 1);
if (!checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) {
return false;
}
$disposable = ['tempmail.org', '10minutemail.com', 'guerrillamail.com', 'mailnator.com', 'trashmail.com'];
return !in_array(strtolower($domain), $disposable, true);
}
/**
* Sanitise a string input: trim, strip slashes, enforce max length.
* Note: htmlspecialchars is applied at the point of output (e.g. in the email body
* or HTML page), not here. Sanitising for storage and sanitising for output are
* different operations.
*/
function sanitizeInput(string $input, int $max_length = 1000): string
{
$input = trim(stripslashes($input));
return mb_strlen($input) > $max_length ? mb_substr($input, 0, $max_length) : $input;
}
/**
* File-based rate limiting keyed on a hashed IP address.
* Works independently of sessions, so it catches bots that don't send cookies.
*/
function checkRateLimit(string $ip): bool
{
$store = sys_get_temp_dir() . '/cf_ratelimit.json';
$data = [];
if (file_exists($store)) {
$data = json_decode(file_get_contents($store), true) ?? [];
}
$now = time();
$key = hash('sha256', $ip);
// Remove expired entries
foreach ($data as $k => $entry) {
if ($now - $entry['first'] >= RATE_LIMIT_WINDOW) {
unset($data[$k]);
}
}
if (!isset($data[$key])) {
$data[$key] = ['count' => 0, 'first' => $now];
}
$data[$key]['count']++;
$data[$key]['last'] = $now;
file_put_contents($store, json_encode($data), LOCK_EX);
return $data[$key]['count'] <= RATE_LIMIT_MAX;
}
/**
* Score the message content for spam indicators.
* Returns an integer — higher = more suspicious.
*/
function checkSpamContent(string $message, string $subject = ''): int
{
$high_risk = ['bitcoin', 'cryptocurrency', 'forex', 'investment opportunity', 'wire transfer'];
$medium_risk = ['click here', 'limited time', 'act now', 'congratulations', 'you have been selected'];
$patterns = [
'/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/', // IP addresses in body
'/https?:\/\/(bit\.ly|tinyurl\.com|t\.co)\/\w+/', // Shortened URLs
'/(.)\1{8,}/', // Repeated characters
];
$score = 0;
$content = mb_strtolower($message . ' ' . $subject);
foreach ($high_risk as $term) {
if (str_contains($content, $term)) $score += 3;
}
foreach ($medium_risk as $term) {
if (str_contains($content, $term)) $score += 2;
}
foreach ($patterns as $pattern) {
if (preg_match($pattern, $message)) $score += 2;
}
return $score;
}
/**
* Build and send the contact email using PHP's mail() function.
* For production on critical sites, consider PHPMailer via SMTP instead.
*/
function sendContactEmail(
string $to_email,
string $from_email,
string $from_name,
string $subject,
string $message
): bool {
if (!filter_var($from_email, FILTER_VALIDATE_EMAIL)) {
return false;
}
$from_name = preg_replace('/[\r\n\t]/', '', $from_name);
$clean_subject = preg_replace('/[\r\n]/', '', $subject);
$noreply = 'noreply@' . YOUR_DOMAIN;
$bounces = 'bounces@' . YOUR_DOMAIN;
$headers = 'From: ' . $noreply . "\r\n";
$headers .= 'Reply-To: ' . $from_email . "\r\n";
$headers .= 'Return-Path: ' . $bounces . "\r\n";
$headers .= 'Message-ID: <' . uniqid('', true) . '@' . YOUR_DOMAIN . ">\r\n";
$headers .= 'Date: ' . date('r') . "\r\n";
$headers .= 'MIME-Version: 1.0' . "\r\n";
$headers .= 'Content-Type: text/plain; charset=UTF-8' . "\r\n";
$headers .= 'Content-Transfer-Encoding: 8bit' . "\r\n";
$headers .= 'X-Mailer: ContactForm/1.0' . "\r\n";
$headers .= 'X-Priority: 3' . "\r\n";
$body = "New contact form submission\n";
$body .= str_repeat('-', 40) . "\n";
$body .= "From: {$from_name} <{$from_email}>\n";
$body .= "Subject: {$clean_subject}\n\n";
$body .= wordwrap($message, 72, "\n", false) . "\n\n";
$body .= str_repeat('-', 40) . "\n";
$body .= 'Host: ' . ($_SERVER['HTTP_HOST'] ?? 'unknown') . "\n";
$body .= 'IP Address: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . "\n";
$body .= 'Timestamp: ' . date('Y-m-d H:i:s T') . "\n";
return mail($to_email, 'Contact Form: ' . $clean_subject, $body, $headers);
}
/**
* Log a submission attempt to a file for monitoring and threshold tuning.
*/
function logSubmission(string $email, bool $success, string $reason = ''): void
{
$entry = implode(' | ', [
date('Y-m-d H:i:s'),
$_SERVER['REMOTE_ADDR'] ?? 'unknown',
$email,
$success ? 'SUCCESS' : 'FAIL',
$reason,
]) . "\n";
// Adjust log path to suit your server — ideally outside the web root
file_put_contents(sys_get_temp_dir() . '/contact_form.log', $entry, FILE_APPEND | LOCK_EX);
}
// ─── Request handling ─────────────────────────────────────────────────────────
// Set the form timestamp when the page is first served (GET request)
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$_SESSION['form_served_at'] = time();
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$is_ajax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
&& strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
$response = ['success' => false, 'message' => ''];
try {
// Sanitise inputs
$name = sanitizeInput($_POST['name'] ?? '', 100);
$email = sanitizeInput($_POST['email'] ?? '', 254);
$subject = sanitizeInput($_POST['subject'] ?? '', 200);
$message = sanitizeInput($_POST['message'] ?? '', 2000);
$token = trim($_POST['recaptcha_token'] ?? '');
// Required fields
if (empty($name) || empty($email) || empty($subject) || empty($message)) {
throw new RuntimeException('Please fill in all required fields.');
}
// Email format + DNS
if (!validateEmail($email)) {
throw new RuntimeException('Please enter a valid email address.');
}
// Honeypot
if (!empty($_POST['website'])) {
error_log('Honeypot triggered from IP: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
// Return fake success — don't tell the bot it was caught
$response = ['success' => true, 'message' => 'Thank you for your message.'];
throw new RuntimeException('__honeypot__');
}
// Submission time check — catch forms submitted suspiciously fast
$served_at = $_SESSION['form_served_at'] ?? 0;
if ((time() - $served_at) < MIN_FORM_TIME) {
throw new RuntimeException('Form submitted too quickly. Please try again.');
}
// File-based rate limiting
if (!checkRateLimit($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0')) {
throw new RuntimeException('Too many submissions from this address. Please try again later.');
}
// reCAPTCHA
$recaptcha = verifyRecaptcha($token, RECAPTCHA_SECRET, $_SERVER['REMOTE_ADDR'] ?? '');
if (!($recaptcha['success'] ?? false) || ($recaptcha['score'] ?? 0) < RECAPTCHA_THRESHOLD) {
error_log('reCAPTCHA failed. Score: ' . ($recaptcha['score'] ?? 'n/a') . ' IP: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
throw new RuntimeException('Security validation failed. Please try again.');
}
// Spam content scoring
$spam_score = checkSpamContent($message, $subject);
if ($spam_score >= SPAM_SCORE_LIMIT) {
error_log("Spam score {$spam_score} from IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
throw new RuntimeException('Your message was flagged by our spam filter. If this is in error, please contact us directly by email.');
}
// Send
if (sendContactEmail(ADMIN_EMAIL, $email, $name, $subject, $message)) {
logSubmission($email, true, 'sent');
// Reset the form timestamp so the same session can't resubmit instantly
$_SESSION['form_served_at'] = 0;
$response = ['success' => true, 'message' => 'Thank you — your message has been sent. We\'ll be in touch shortly.'];
} else {
logSubmission($email, false, 'mail() returned false');
throw new RuntimeException('There was a problem sending your message. Please try again or contact us directly by email.');
}
} catch (RuntimeException $e) {
if ($e->getMessage() !== '__honeypot__') {
$response['message'] = $e->getMessage();
logSubmission($_POST['email'] ?? '', false, $e->getMessage());
}
}
if ($is_ajax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode($response);
exit;
}
// Non-JS fallback
if ($response['success']) {
header('Location: /contact-success.html');
exit;
}
// Otherwise fall through to re-render the form with $response['message']
}
?>
Testing Checklist
Before going live, verify the following:
- Valid submission: Fill in all fields correctly and confirm you receive the email in your inbox, not spam.
- Missing fields: Submit with each required field blank and confirm an appropriate error is returned.
- Invalid email: Submit with a malformed email address and confirm it is rejected.
- Honeypot: Use your browser's developer tools to populate the hidden field and submit — confirm it is silently accepted but no email arrives.
- Rate limiting: Submit four times in quick succession from the same IP — the fourth should be blocked.
- Speed check: Submit immediately after page load (within 5 seconds) — confirm it is rejected.
- Header injection: Try setting the subject to
Test\r\nBcc: someone@example.com— confirm it is stripped. - No-JavaScript fallback: Disable JavaScript in your browser, submit the form, and confirm you are redirected to the success page correctly.
- Keyboard navigation: Tab through the entire form using only the keyboard and confirm every field is reachable and usable.
- Screen reader: Test with NVDA (Windows) or VoiceOver (Mac/iOS) and confirm error messages and the success state are announced.
Conclusion
Building an effective contact form is not glamorous work, but getting it right matters. Every code example in this guide is production-ready — not pseudo-code. The key principles are:
Security through depth: No single technique is sufficient. reCAPTCHA, file-based rate limiting, honeypots, spam scoring, and input validation all work together.
Use cURL for external calls: file_get_contents on remote URLs fails silently on many hosts. cURL gives you proper error handling and timeout control.
Rate limit server-side, not session-side: Session-based rate limiting doesn't stop bots. File-based or database-backed storage keyed on IP does.
From headers must use your own domain: Putting the submitter's email in the From header causes SPF failures. Use Reply-To for their address instead.
Progressive enhancement: The form should work without JavaScript and be improved by it, not depend on it.
Log everything and tune your thresholds: Spam scoring thresholds set in isolation will either block legitimate messages or let spam through. Review your logs, watch what gets caught and what gets through, and adjust accordingly.
