← Back to blog
19 July 2026

Stop Contact Form Spam Without reCAPTCHA (PHP Guide)

We killed contact form spam on our own site and three client rebuilds without touching Google reCAPTCHA: a honeypot field, a two-second time-trap, and a CSRF token, all in plain PHP that runs on the cheapest cPanel plan.

Why reCAPTCHA is overkill for a contact form nobody's actually attacking

Most of the client sites we take over already have Google reCAPTCHA bolted onto the contact form, usually v2 with the "I'm not a robot" checkbox, sometimes v3 running invisibly in the background. Whoever built the site added it because that's what you're supposed to do. Nobody asked whether a five-page plumber's brochure site getting twelve enquiries a month actually needs the same defence as a bank login page.

The bot traffic problem itself is real. Imperva's 2025 Bad Bot Report found bad bots made up 37% of all internet traffic in 2024, up from just over 30% the year before, and combined good-plus-bad automated traffic crossed 51% of the web for the first time in over a decade. South Africa isn't exempt from that, we've watched the same generic scripted traffic hit small business sites we manage locally, and even mainstream hosting guidance on stopping contact form spam treats it as a standard problem to solve, not a hypothetical one. So the threat isn't imaginary, it's just aimed at a much bigger set of targets than "the contact form on a landscaping company's site."

What reCAPTCHA actually costs a small client site: an extra script load from Google's servers, a badge sitting in the corner of the page (or invisible tracking for v3), and a visitor data flow that goes straight to Google. That last part is where it gets awkward under South African law. POPIA's cross-border transfer rules require that moving personal information out of the country rest on one of four grounds: adequate legal protection in the receiving country, the data subject's consent, contractual necessity, or a genuine benefit to the subject where consent isn't reasonably obtainable. Google's contractual terms cover most of that in practice, but it's still one more third party in your data flow, one more line in the privacy policy, and one more thing a client asks about when they're trying to tidy up their POPIA compliance. For a form catching fifteen spam submissions a week, that's a disproportionate amount of legal and technical overhead for a problem you can solve with about 60 lines of PHP and zero external requests.

The three-layer defence: honeypot, time-trap, CSRF token

Every contact form we build now, whether it's a static HTML site with a PHP mailer or a client's existing WordPress form, runs three checks before we even look at what the visitor typed:

Each catches a different kind of bot. The honeypot catches the dumb, high-volume scripts that fill in every input field they find. The time-trap catches the scripted submitters that skip rendering the page and just POST straight to the handler. The CSRF token, borrowed from OWASP's synchronizer token pattern, stops forged and replayed requests that don't originate from a real page load at all. None of it needs JavaScript, an API key, or a monthly subscription. It's plain PHP, which matters when your client is on a R100 to R150 a month shared cPanel plan with no Redis, no custom rate limiter, and no budget for anything that needs its own dashboard.

We rebuilt our own site on static HTML earlier this year and skipped reCAPTCHA on the new contact form entirely, and we've since carried the same setup into three client rebuilds. None of them run WordPress plugin bloat for spam filtering, none of them have an Akismet subscription, and none of them send a single byte to Google before the enquiry reaches our inbox. Spam volume on the sites we've deployed this on has stayed close to zero, and the couple of hits we do see land safely in the log file mentioned further down rather than in anyone's inbox.

Building a honeypot field bots can't resist

The trick with a honeypot is making it invisible to a person filling in the form on a phone, while still looking like a normal, fillable field to a script that's just grabbing every input tag on the page. Two mistakes we see constantly: hiding the field with display:none (some spam scripts specifically check computed style and skip anything hidden that way), and naming the field something like website or email (browser autofill will happily fill that in for a real human, and now you've flagged your own visitor as a bot).

What works: position the field off-screen instead of hiding it outright, give it an unusual name attribute that autofill won't recognise, and add tabindex="-1" and autocomplete="off" so a keyboard user tabbing through the form never lands on it.

<style>
.nl-hp-wrap {
  position: absolute;
  left: -9999px;
  top: -9999px;
  height: 0;
  overflow: hidden;
}
</style>

<div class="nl-hp-wrap" aria-hidden="true">
  <label for="nl_confirm_details">Leave this field blank</label>
  <input type="text" id="nl_confirm_details" name="nl_confirm_details"
    tabindex="-1" autocomplete="off">
</div>

On the handler side, if that field arrives with anything in it, you know it wasn't a person. We don't reject it with an error though, we treat it like a normal successful submission and quietly drop the data instead (more on why in the logging section).

One more honeypot detail: don't reuse the same field name across every site you build. Scripted spam tools that specifically target agency-built sites (and there are more of these than you'd think, since a lot of agencies clone the same theme or boilerplate across dozens of clients) learn to skip fields named honeypot, hp_field, or bot_check, because those names show up constantly in public WordPress plugin source. We generate a fresh, meaningless field name per client build, something like nl_confirm_details above, or a randomised variant per project, so there's no pattern for a scraper to learn across the sites we've shipped.

The time-trap: rejecting anything that arrives in under two seconds

Bots that skip rendering the page and POST directly to your form handler don't spend any time actually reading the fields or typing an answer. A real person, even someone moving fast, needs a few seconds to look at a "Your Name" field and type something into it. So we stamp the time the form was rendered, and compare it to the time the submission lands.

<?php
session_start();
$_SESSION['nl_form_rendered'] = time();
?>
<input type="hidden" name="nl_ts" value="<?php echo $_SESSION['nl_form_rendered']; ?>">

Then on submission:

<?php
$submitted_ts = isset($_POST['nl_ts']) ? (int) $_POST['nl_ts'] : 0;
$elapsed = time() - $submitted_ts;

if ($elapsed < 2) {
    // Don't tip the bot off, just look like a normal success
    header('Location: /thanks.html');
    exit;
}
?>

Two seconds is the threshold we landed on after watching our own log files for a few weeks. Set it too high, five or six seconds, and you'll start catching genuine visitors on a slow mobile connection who filled the form quickly. Set it too low and it barely filters anything. Two seconds has caught real spam consistently across the client sites we've deployed it on without catching a single legitimate enquiry, but it's worth watching your own logs for a couple of weeks after you turn it on, because every site's visitor behaviour is a bit different.

Adding a CSRF token with random_bytes() and hash_equals()

The honeypot and time-trap deal with spam bots. The CSRF token deals with something slightly different: making sure the POST request hitting your handler actually came from a form you rendered in this visitor's session, not a forged request built by a script that never loaded your page at all, or a stale replayed submission. The standard PHP pattern generates a random token when the form is rendered, stores it in the session, and checks it matches on submission.

<?php
session_start();
if (empty($_SESSION['nl_csrf_token'])) {
    $_SESSION['nl_csrf_token'] = bin2hex(random_bytes(32));
}
?>
<input type="hidden" name="nl_csrf" value="<?php echo $_SESSION['nl_csrf_token']; ?>">

The important detail most PHP tutorials get wrong is the comparison itself. Using == or === to compare two strings leaks timing information, an attacker can measure how long the comparison takes and work out the correct token byte by byte. PHP's hash_equals() runs in constant time regardless of where the strings first differ, which is what makes it safe for comparing security tokens.

<?php
$submitted_token = $_POST['nl_csrf'] ?? '';
$session_token = $_SESSION['nl_csrf_token'] ?? '';

if (!$session_token || !hash_equals($session_token, $submitted_token)) {
    http_response_code(403);
    exit('Form session expired. Please refresh and try again.');
}
?>

Unlike the honeypot and time-trap, a failed CSRF check does get an honest error message. A real visitor can hit this legitimately (session expired, form left open in a tab overnight), so it's worth telling them to refresh rather than pretending it worked.

The full working PHP contact handler

Here's all three checks combined into one handler, along with basic field validation and a plain mail() send. Swap in your own addresses and, if your host supports it, swap mail() for PHPMailer with SMTP authentication, since plain mail() on shared hosting is more likely to land in spam itself.

<?php
// contact-handler.php
session_start();

// 1. CSRF check
$session_token = $_SESSION['nl_csrf_token'] ?? '';
$submitted_token = $_POST['nl_csrf'] ?? '';
if (!$session_token || !hash_equals($session_token, $submitted_token)) {
    http_response_code(403);
    exit('Form session expired. Please refresh and try again.');
}

// 2. Honeypot check, fail silently
if (!empty($_POST['nl_confirm_details'])) {
    nl_log_near_miss('honeypot', $_POST);
    header('Location: /thanks.html');
    exit;
}

// 3. Time-trap check, fail silently
$submitted_ts = isset($_POST['nl_ts']) ? (int) $_POST['nl_ts'] : 0;
if ((time() - $submitted_ts) < 2) {
    nl_log_near_miss('time-trap', $_POST);
    header('Location: /thanks.html');
    exit;
}

// 4. Basic field validation
$name = trim(strip_tags($_POST['name'] ?? ''));
$email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
$message = trim(strip_tags($_POST['message'] ?? ''));

if (!$name || !$email || !$message) {
    http_response_code(422);
    exit('Please fill in all fields with a valid email address.');
}

// 5. Send it
$to = 'hello@yourdomain.co.za';
$subject = 'New enquiry from ' . $name;
$body = "Name: $name\nEmail: $email\n\nMessage:\n$message";
$headers = 'From: noreply@yourdomain.co.za' . "\r\n" . 'Reply-To: ' . $email;

mail($to, $subject, $body, $headers);

unset($_SESSION['nl_csrf_token']);
header('Location: /thanks.html');
exit;

function nl_log_near_miss($reason, $data) {
    $line = date('c') . ' | ' . $reason . ' | ' .
        ($_SERVER['REMOTE_ADDR'] ?? 'unknown') . ' | ' .
        json_encode($data) . "\n";
    file_put_contents(__DIR__ . '/spam-log.txt', $line, FILE_APPEND);
}
?>

Log near-misses instead of silently dropping them

The nl_log_near_miss() function above writes every trapped submission to a plain text file instead of throwing it away. This matters more than it sounds like it should, because we've been burned by it once already. A client called to say a lead they were expecting by email never arrived. We checked spam-log.txt and found the culprit: a real enquiry, submitted in 1.8 seconds, from a visitor whose browser had autofilled the entire form from a saved profile. Our time-trap caught it as a false positive.

We moved the threshold on that site up to 2.5 seconds and added a second check: if the honeypot field is empty and the message text looks like a genuine sentence rather than a URL dump, don't drop it even if it's fast, just flag it in the log for a quick manual look. Without the log file, that lead would have vanished with no record it ever existed and no way to know our own trap had eaten it. Check the log weekly. If you ever see the same real name and message pattern showing up as a near-miss more than once, that's your signal to loosen the threshold, not a signal that the person is a bot.

When you still actually need Turnstile

It's worth being honest about what this three-layer setup doesn't do. It won't stop a determined human spammer typing garbage into your form by hand, and it won't stop a sophisticated bot that renders JavaScript, waits a realistic amount of time, and fills only the visible fields. Those exist, but they're the minority, and they're expensive to run at scale. Most of what hits a small business contact form is cheap, high-volume, unsophisticated scripting, exactly the kind of traffic driving automated traffic past 51% of the web in the first place. That's the layer this defence is built for, and for a R150 a month cPanel site, it's the layer that matters. There are situations where you do want something stronger:

Cloudflare's Turnstile is the option we reach for at that point, positioned specifically as a privacy-preserving CAPTCHA alternative that doesn't build a tracking profile on your visitors the way reCAPTCHA does. It's still an extra script and an extra dependency, so we only add it where the honeypot and time-trap approach has actually proven insufficient, not by default.

If your site's still running the reCAPTCHA every agency bolts on by habit, or you're not sure whether your current contact form is quietly leaking enquiries into a spam folder nobody checks, that's the kind of thing worth having someone actually look at.

Need something custom built? We work with founders and agencies who know what they want and need someone who can actually deliver it.

Let's chat