Web Development

How I Reduced Contact Form Bot Spam Without Adding CAPTCHA

I wanted to reduce automated submissions on the Cordinant contact form without making every visitor complete a CAPTCHA. The solution became a combination of small server-side checks: CSRF protection, a honeypot, submission timing, validation and rate limiting.

Contact form with hidden anti-bot checks before email reaches the inbox

A contact form is one of the simplest parts of a website. At least it looks that way.

A visitor enters a name, email address and message, clicks Send, and the website delivers the enquiry. There is not much to think about until automated submissions start appearing.

That happened with the contact form on my personal website - Cordinant. I wanted to reduce bot submissions, but I did not particularly like the most obvious solution: putting a CAPTCHA in front of every person who wanted to contact me.

So I tried a different approach.

Instead of asking visitors to prove that they were human, I made the server look for signs that a submission probably was not.

I Wanted the Protection to Be Mostly Invisible

For a normal visitor, I wanted the contact page to remain boring.

Open the page. Write a message. Click Send.

There should not be an extra puzzle or another third-party widget to interact with unless there was a real reason for it. This meant moving most of the protection behind the visible form.

The PHP application could examine a submission in several different ways before deciding whether it should be allowed to reach my inbox. Did the request come through the real form? Did it fill something that a human would never see? How quickly was it submitted? How many other messages had recently come from the same source?

None of those questions provides a perfect answer by itself. That became an important part of the design.

I was not trying to find one clever test that could identify every bot. I was trying to make automated submissions pass several small tests before they could do anything useful.

The First Trap Is a Field Nobody Should Fill In

One of the simplest checks is a honeypot.

The contact form contains an additional field called website. It is present in the HTML, but CSS moves it away from the visible interface and it is removed from the normal keyboard tab order.

A real visitor has no reason to interact with it.

Honeypot field left empty by a human but filled by an automated bot
A honeypot adds a field that legitimate visitors never need to touch, but simple form bots may fill automatically.
<input
    type="text"
    name="website"
    autocomplete="off"
    tabindex="-1"
    class="contact-honeypot"
>

The CSS can be very simple:

.contact-honeypot {
    position: absolute;
    left: -9999px;
}

A basic form bot may see the situation differently. It finds fields called name, email, website and message, and tries to populate all of them.

That makes the otherwise useless website field useful.

On the server, PHP can check whether it contains anything:

<?php
if (!empty($_POST['website'] ?? '')) {
    exit;
}
?>

If it does, the message does not need to go any further.

Note:

There is also no particular reason to explain this to the bot. A suspicious request can receive an ordinary-looking response even though no email was sent.

Giving detailed feedback about which anti-spam check failed would only make the form easier to probe.

A honeypot is wonderfully simple, but that simplicity also means it cannot be the entire defence. A bot can learn not to fill hidden fields.

Humans Usually Need More Than One Second to Write a Message

The next signal comes from something the visitor is already doing: spending time on the page.

When Cordinant displays the contact form, PHP records the current time in the session.

<?php
$_SESSION['contact_form_time'] = time();
?>

When the POST request arrives, the server can compare the stored value with the current time.

Timeline comparing normal contact form completion with an instant bot submission
Submission time is not proof by itself, but an almost immediate POST can become another useful signal when combined with other checks.
<?php
$startedAt = $_SESSION['contact_form_time'] ?? 0;

if ($startedAt && (time() - $startedAt) < 4) {
    exit;
}
?>

There is nothing magical about four seconds. I considered a range of roughly three to five seconds when working on the form.

The interesting part is the difference in behaviour.

A person needs to look at the page, enter a name, type an email address and write some kind of message. A request that arrives almost immediately after the form was served looks different.

Note:

Timing is not a verdict. Someone may use autofill, visitors type at different speeds, and a more capable bot can simply wait before submitting.

So timing is not a verdict. It is another signal.

A Valid POST Has to Come Through the Form

The contact page also creates a CSRF token when the form is loaded and stores it in the visitor's session.

For example:

<?php
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
?>

The same value is placed in a hidden form field:

<input
    type="hidden"
    name="csrf_token"
    value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"
>

When the form comes back as a POST request, PHP compares the submitted token with the session value.

<?php
$token = $_POST['csrf_token'] ?? '';

if (
    empty($_SESSION['csrf_token']) ||
    !hash_equals($_SESSION['csrf_token'], $token)
) {
    exit;
}
?>

CSRF protection is not really an anti-spam mechanism. Its main purpose is different: protecting an application from forged requests.

But in this particular architecture it has a useful secondary effect. A very primitive script cannot just discover the contact endpoint and repeatedly fire arbitrary POST requests at it. It first needs to interact with the application sufficiently to obtain a valid form and session.

Again, this is not enough to stop a determined bot.

It is one more barrier.

Layered contact form protection using CSRF, honeypot, timing, rate limiting and validation
Cordinant does not depend on a single anti-bot test. A submission has to pass several independent checks before it can reach the mail-sending stage.

I Still Don't Trust What the Browser Says

The visible form already contains familiar browser-side validation. An email field can be declared like this:

<input type="email" name="email" required>

That is useful for people. It can catch a missing field or an obviously malformed email before the form is submitted.

But the server cannot assume that the browser was involved at all.

An automated client can send a POST request directly to the PHP endpoint. It does not have to render the form, obey required, respect type="email" or interact with the interface in the way I intended.

Bot sending a POST request directly to PHP and bypassing browser form validation
Browser validation improves the form experience, but a bot can bypass the browser entirely. The submitted data still has to be checked by PHP.

So the values are checked again on the server.

<?php
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');

if ($name === '' || $email === '' || $message === '') {
    // Validation error
}

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    // Invalid email
}
?>

I also added sensible length limits. A name does not need to contain thousands of characters, and neither does an email address.

For Cordinant, the limits considered were 100 characters for the name, 254 for the email address and 5,000 for the message.

  • Name: up to 100 characters
  • Email: up to 254 characters
  • Message: up to 5,000 characters

These checks are partly about spam, but not entirely. Once a public endpoint accepts arbitrary input from the internet, basic validation is useful regardless of whether the person on the other side is a spammer, a bot or simply someone who submitted unexpected data.

The Email Address Can Be More Dangerous Than It Looks

There is another reason not to blindly trust contact-form values.

Some of them may eventually be used while constructing an email. That creates the possibility of email header injection if values are handled carelessly.

A malicious value could attempt to introduce new lines followed by headers such as BCC:, CC: or Content-Type:.

One simple check is to reject carriage-return or newline characters in values that may reach email headers:

<?php
if (preg_match('/[\r\n]/', $email)) {
    exit;
}
?>

The same principle applies to other user-controlled values if they are used when constructing headers.

Important:

A contact form is not only an anti-spam problem. It is also an interface between anonymous internet traffic and an email-sending system.

Escaping the Message Is a Different Problem Again

The message field creates another question.

Someone can submit text that looks like HTML:

<script>...</script>

Or links and other markup.

That does not mean the application should treat it as trusted HTML.

When user content is displayed, it can be escaped with htmlspecialchars(). If the contact email itself uses HTML, the message can still be escaped before line breaks are added:

<?php
$safeMessage = nl2br(
    htmlspecialchars($message, ENT_QUOTES, 'UTF-8')
);
?>

This is not a bot detector. A perfectly legitimate visitor could include angle brackets in a message, while a bot could send completely harmless-looking plain text.

It is simply another example of why several different concerns meet inside something as ordinary as a contact form.

What If the Bot Learns to Behave Like a Human?

The limitations of the first checks become more obvious if I imagine a slightly better bot.

Instead of blindly submitting data, it could do this:

  • GET contact.php
  • Keep the session and CSRF token
  • Leave the honeypot empty
  • Wait five seconds
  • POST the message
Bot bypassing honeypot and timing checks before being stopped by rate limiting
A bot can learn to leave the honeypot empty and wait before submitting. Rate limiting adds another barrier when the earlier signals are no longer enough.

At that point, several of my checks have been successfully passed.

This is where rate limiting becomes much more useful.

The server can keep track of recent submissions and reject a source that sends too many messages within a particular period.

For a small contact form, limits such as three messages in ten minutes or a broader limit of ten per hour are examples of the kind of thresholds I considered.

Example Limit Purpose
3 messages / 10 minutes Stops rapid repeated submissions
10 messages / hour Adds a broader long-term limit

The exact numbers are not the interesting part. They depend on the website and what normal usage looks like.

The important difference is that rate limiting asks another question entirely.

  • The honeypot asks what the client filled in.
  • Timing asks how quickly it acted.
  • CSRF asks whether it interacted with a valid form and session.
  • Rate limiting asks how often it keeps coming back.

A bot can imitate one piece of human behaviour and still look suspicious when viewed from another angle.

Rate Limiting Is Only as Good as the IP You Trust

IP-based limiting introduces its own complication: determining which IP address actually belongs to the client.

When an application receives traffic directly, $_SERVER['REMOTE_ADDR'] is the basic starting point.

Things become more complicated when a trusted proxy or service such as Cloudflare sits in front of the application. In that situation, information supplied by the proxy may be needed to determine the original client.

Important:

I would not blindly trust any X-Forwarded-For header sent to the application. A client can supply arbitrary HTTP headers unless the application knows the request came through trusted proxy infrastructure.

If an application accepts an arbitrary forwarded IP without first establishing that the request came through trusted proxy infrastructure, a bot may simply claim to have a different address.

A rate limiter built on untrusted identity information is not much of a limiter.

Blocking Bots Is Useful. Knowing What Was Blocked Is Better.

Initially, it is tempting to make every suspicious condition end with exit; and forget about it.

That stops the request, but it also removes useful information.

For some rejected submissions, I would rather record a small security event containing things such as the timestamp, IP address, user agent and reason for rejection.

A log might say:

2026-09-01 18:43
Reason: honeypot

Or:

Reason: submitted-too-fast

Or:

Reason: rate-limit

This makes the protection observable.

If almost everything is being stopped by the honeypot, that tells me something. If requests begin consistently passing the early checks and reaching the rate limiter, that tells me something else.

Note:

A security log does not need everything. I would not put passwords, CSRF tokens or complete private contact messages into it just because those values are available.

The purpose is to understand what the protection is doing, not to create another collection of sensitive data.

SMTP Is the Last Step, Not the First

Eventually a legitimate contact request needs to become an email.

For Cordinant, the important part is the order in which that happens.

POST → method check → CSRF → honeypot → timing → rate limit → required fields → length limits → email validation → header-injection check → sanitisation → SMTP

Contact form request passing security and validation checks before SMTP sends the email
Only a request that passes the anti-spam, security and validation checks is allowed to reach authenticated SMTP and the Cordinant inbox.

Only then does the application try to send anything.

This order prevents an obviously rejected request from unnecessarily reaching the mail-sending stage. A bot should not be able to make the application repeatedly open SMTP connections before it has passed the cheaper checks.

For Cordinant, I use authenticated SMTP rather than relying on a basic PHP mail() call.

While working on the contact system, I also had to correct the SMTP configuration itself. The setup eventually used SSL on port 465.

Stage Cordinant Setup
Transport Authenticated SMTP
Encryption SSL
Port 465

So the actual journey of a successful message is closer to this:

Visitor → contact.php → anti-spam and validation checks → authenticated SMTP → Cordinant inbox

The visible Send button is only the beginning of that path.

SPF, DKIM and DMARC Solve Another Part of the Problem

Email infrastructure adds another set of familiar security terms: SPF, DKIM and DMARC.

They matter, but they solve a different problem.

Those mechanisms operate at the domain email level. They help establish which systems are authorised to send mail for a domain, authenticate messages and define how receiving systems should handle authentication failures.

They do not prevent a bot from filling in my contact form.

That distinction matters because it is easy to put everything under a broad label such as "email security." The contact endpoint and the domain's outgoing email identity are different layers, and they need different protections.

Why I Still Haven't Added CAPTCHA

None of this means CAPTCHA is bad.

It also does not mean that a honeypot, timing check and rate limiter will stop every automated system indefinitely.

The question for me was whether every legitimate Cordinant visitor should encounter additional friction before I had evidence that it was necessary.

For now, the contact form can rely on a combination of honeypot, timing, CSRF protection, server-side validation and rate limiting.

Most of that is invisible to somebody simply trying to write to me.

Current invisible contact form protection compared with optional Cloudflare anti-bot layers
The current approach keeps protection mostly invisible. Turnstile and Cloudflare-level controls remain additional options if the traffic eventually requires them.

If that stops being sufficient, there are other layers available.

Cloudflare Turnstile would be one possible next step. Cloudflare's firewall and bot-related controls could provide another layer at the infrastructure level, and repeated abusive sources could potentially be blocked temporarily.

I prefer that order.

Start with protection that does not change the normal experience. Observe what is actually happening. Add more friction when the traffic gives you a reason to add it.

The Interesting Part Is Everything the Visitor Doesn't See

The Cordinant contact page still looks like a contact page.

There is a name field, an email field, a message box and a button.

A real visitor does not see the honeypot. They do not see the timestamp stored in the session. They do not see the CSRF comparison, rate limiter, header checks or sanitisation. They certainly do not need to think about when the SMTP connection is opened.

That hidden part ended up being much more interesting than the visible form.

It is also something I keep encountering while building software. A feature can occupy a tiny amount of space in an interface while most of the work required to make it dependable happens somewhere behind it.

The Send button is simple. Everything that decides whether pressing it should actually send something is not.

Related Products

Useful Cordinant products connected with this article. These tools are built for developers, freelancers, founders and small teams who want practical self-hosted solutions.

Captain’s Toolkit

Captain’s Toolkit

Marine

Self-hosted offline-first PWA source code for vessel records, voyages, maintenance, fuel, crew, safety and operational checklists.

  • Vessel Management
  • Voyage Log
  • Fuel Tracking
$89
My Budget - PHP Finance App Source Code

My Budget - PHP Finance App Source Code

Business

A self-hosted PHP and MySQL personal finance application with transactions, financial dashboards, savings goals, reports, CSV export and full source code.

  • Everything Needed for a Modern Finance Application
  • Dashboard Analytics
  • Visual overview of financial activity and trends.
  • Transactions Management
$69

Related Articles

More articles about software, websites, UX design, content, SEO and the ideas behind independent digital projects.

Building Digital Products as a Solo Founder

Building Digital Products as a Solo Founder

Digital Products

Building software alone involves far more than writing code. It means moving between development, design, documentation, SEO, content and product decisions -often within the same day. This article explores the reality of solo product development, including its freedom, invisible work, limitations and constant trade-offs.

Published
August 3, 2026 17 min read
Is PHP Still Relevant in 2026? A Practical Look at Modern PHP

Is PHP Still Relevant in 2026? A Practical Look at Modern PHP

Web Development

PHP has supposedly been "dying" for years, yet it continues to power millions of websites and modern web applications. After building Cordinant and several self-hosted PHP products, I found the discussion far more nuanced than internet debates suggest. Here's why PHP still matters—and where it doesn't.

Published
July 22, 2026 21 min read
The Biggest Challenges I Faced While Building My Own Digital Products

The Biggest Challenges I Faced While Building My Own Digital Products

Digital Products

Building digital products sounded straightforward until I started doing everything myself. From feature creep and database decisions to documentation, pricing, marketing, and launch anxiety, these were the biggest challenges I encountered while creating products like My Budget, Captain's Toolkit, and Cordinant.

Published
June 8, 2026 7 min read

Explore More Cordinant Resources

Read more articles about PHP, UX design, SEO and product development, or browse installable code products and self-hosted software foundations.