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.
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
It stops the simplest bots, which fill in every field. Smarter ones get around it, so it’s worth adding a time ticket and rate limits. Together, these three layers stop the vast majority of automated spam without any CAPTCHA.
reCAPTCHA sends data about the visitor to Google, so it has to be described in your privacy policy, and its legal basis in the EU is questionable. If you really need a CAPTCHA, consider privacy-focused solutions such as Cloudflare Turnstile or Friendly Captcha, and describe them in the policy too.
No trap will stop a human. What helps are limits on submissions from one connection, rejecting messages with many links and checking whether the domain of the email address accepts mail at all. You simply delete the rest, but there isn’t much of it.
The logic is the same. Popular form plugins have honeypots and rate limits, often in add-ons. Just check that the anti-spam plugin doesn’t send the message content to an external service, because then you also have to describe that in your privacy policy.
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.


