Contact form without spam or CAPTCHA: 6 safeguards

How to stop contact form spam without CAPTCHA: a honeypot, a time ticket, rate limits and validation. PHP code to copy and the bug that sends out spam.

Illustration: a contact form with a blue shield off which small robots bounce
AI-generated illustration

A contact form is the simplest way for a customer to reach you and, at the same time, a favorite target of bots. Spam in your inbox is the lesser evil. It’s worse when a badly written form starts sending out spam from your domain by itself, and your emails then end up in customers’ “Spam” folders.

The most common answer to spam is CAPTCHA: click the pictures with fire hydrants. It works, but it has a cost. Some people give up on sending a message, and reCAPTCHA sends visitors’ data to Google. In this article I show 6 safeguards that the customer won’t notice at all. This is how the form on my website and in the projects I build for clients works.

1. A bot trap (honeypot)

Bots fill in every field they find in the code. So you add a field that a human can’t see. If it arrives filled in, a bot sent it:

<div class="hp" aria-hidden="true">
  <label for="www">Leave this field empty</label>
  <input id="www" name="website" type="text" tabindex="-1" autocomplete="off">
</div>
.hp { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
if (trim($_POST['website'] ?? '') !== '') {
    // we pretend it succeeded: the bot will not find out it hit the trap and will not try again
    echo json_encode(['ok' => true]);
    exit;
}

Three details make a difference. The field has an ordinary name (“website”), not “honeypot”. tabindex="-1" means a person navigating with a keyboard won’t land in it. aria-hidden hides it from screen readers. Don’t use display: none, because some bots skip such fields.

2. A time ticket

A human needs a few dozen seconds to fill in a form. A bot sends it in a fraction of a second, often without even loading the page. So when the form loads you issue a ticket with a time and a signature, and when it is submitted you check whether it is genuine and how many seconds old it is:

const SEKRET = 'a long random string, e.g. bin2hex(random_bytes(32)), kept outside the code';

function bilet_nowy(): string
{
    $t = (string) time();
    return $t . '.' . hash_hmac('sha256', $t, SEKRET);
}

function bilet_ok(string $bilet): bool
{
    if (!preg_match('/^(\d{10})\.([a-f0-9]{64})$/', $bilet, $m)) return false;
    if (!hash_equals(hash_hmac('sha256', $m[1], SEKRET), $m[2])) return false;   // forged
    $wiek = time() - (int) $m[1];
    return $wiek >= 3 && $wiek <= 7200;   // no faster than 3 s and no later than 2 h
}

You can put the ticket in the form as a hidden field. If the page is static or cached, fetch it with a script from a separate address when someone starts filling in the form. An added bonus: a bot without JavaScript won’t get it at all. The HMAC signature means a bot can’t be taught to “enter a time from a minute ago”, because without the secret it can’t generate a valid signature.

3. Submission limits

Even a clever bot has limited time. A limit per connection, for example 5 messages in 10 minutes, and a shared daily limit protect your inbox from being flooded:

function limit_ok(string $ip, int $max = 5, int $okno = 600): bool
{
    // a hash of the IP instead of the address itself: you do not store personal data
    $plik = sys_get_temp_dir() . '/formularz-' . hash('sha256', $ip . SEKRET) . '.json';
    $fh = fopen($plik, 'c+');
    flock($fh, LOCK_EX);   // two submissions at once will not overwrite each other's counter
    $teraz = time();
    $czasy = array_filter(json_decode(stream_get_contents($fh), true) ?: [], fn($t) => $t > $teraz - $okno);
    $ok = count($czasy) < $max;
    if ($ok) $czasy[] = $teraz;
    ftruncate($fh, 0);
    rewind($fh);
    fwrite($fh, json_encode(array_values($czasy)));
    flock($fh, LOCK_UN);
    fclose($fh);
    return $ok;
}

if (!limit_ok($_SERVER['REMOTE_ADDR'] ?? '')) {
    http_response_code(429);
    exit;
}

If the site sits behind a proxy server (Cloudflare, Caddy, Nginx), REMOTE_ADDR is the address of that server, not of the client. You then have to take the real address from a header set by your server, but only if you trust whoever sets it. Otherwise a bot will enter any address it likes and get around the limit.

4. Email validation: the trap that makes a form send out spam

This is the most important point in this article. A typical PHP form sends a message to you and sets the Reply-To header to the customer’s address, so that you can reply with one click. The address is usually checked like this:

filter_var($email, FILTER_VALIDATE_EMAIL)

The problem is that, according to the standard, the part of an email address before the @ can be in quotation marks, and inside it there can be characters preceded by a backslash, including a newline character. I tested this in PHP 8.3 and 8.4: an address in quotation marks with a backslash and a newline inside passes this validation. Inserted into the Reply-To header, it adds a new header, for example Bcc: with a list of other people’s addresses. Your form then sends spam from your domain, through your mail server.

The fix is simple. A form only needs ordinary addresses, so reject quotation marks, backslashes and control characters:

function email_ok(string $e): bool
{
    return strlen($e) <= 120
        && filter_var($e, FILTER_VALIDATE_EMAIL) !== false
        && !preg_match('/[\x00-\x20\x7F"\\\\(),:;<>\[\]]/', $e);
}

The second rule: put nothing from the user into mail headers except an address checked this way. It is best to encode the subject and the name (=?UTF-8?B?...?=) or use a mail-sending library, for example PHPMailer or Symfony Mailer, which take care of the headers themselves. I found and fixed this bug during a security audit of my own website, so I know it affects real code, not just textbooks.

5. Content and sender domain

Spam has its habits. A few simple rules filter out a lot of what gets past the traps:

$linki = static fn(string $s): int => preg_match_all('~https?://|www\.~i', $s);
if ($linki($imie) > 0 || $linki($tresc) > 3) {
    odrzuc('linki');   // a link in the name or more than 3 links in the message
}

// the email domain must accept mail (an MX or A record)
$domena = substr(strrchr($email, '@') ?: '', 1);
if ($domena === '' || (!checkdnsrr($domena, 'MX') && !checkdnsrr($domena, 'A'))) {
    odrzuc('domena');
}

Checking the domain has one more advantage: it catches typos by real customers, for example gmial.com. Instead of rejecting silently, ask them to correct the address, because otherwise your reply will never arrive.

6. A computational task for costly forms

If submitting the form costs something, for example it runs AI, a paid API or a large report, it’s worth adding a computational task (proof-of-work). In the background, the browser looks for a number that, together with the string it received, gives a SHA-256 hash starting with a dozen or so zero bits. For a human it takes a fraction of a second in the background, while a bot that wants to send a thousand submissions has to pay a thousand times in processor time. This is how I protect the tools on my website, such as Check your website and the competitor audit. An ordinary contact form usually doesn’t need it.

What not to do

  • Don’t tell the bot why it was rejected. A reply like “the website field should be empty” is instructions for getting around the trap.
  • Don’t store the content of rejected messages. Counters are enough: how many rejections and for what reason. Less data means fewer GDPR obligations.
  • Don’t block countries or entire networks blindly. A customer on vacation or in a company with a VPN wants to write to you too.
  • Don’t forget messages for humans. When the limit hits a real customer, show a phone number or an email address, not just an error code.

When you do need a CAPTCHA

If spam keeps arriving despite these safeguards, it usually means a human or a bot driving a real browser. A CAPTCHA makes sense then. Choose a solution that is invisible to most people and privacy-focused, describe it in your privacy policy and enable it only where the problem really occurs.

Summary

A bot trap, a time ticket and rate limits stop automated spam without any CAPTCHA. Correct email validation makes sure the form doesn’t turn into a spam relay from your domain. If emails from your company still land in customers’ spam anyway, check your domain settings too, which I cover in the article SPF, DKIM and DMARC step by step.

Do you want me to check the forms and security of your website? Order a security audit or write to me. I reply within 24 hours, and new clients get 20% off their first order.

Frequently asked questions

Related services

Have a similar project?

Describe what you need and I’ll send a quote within 24 hours. New clients get 20% off their first project.

Get a quoteBook a call (20 min)

Read more