Protecting a form from spam without a Captcha: from Akismet to my homegrown anti-spam
Back in November 2016, I published a post on my WordPress blog titled “How do you set up Akismet?”. I explained how to enable the anti-spam plugin bundled with WordPress, grab an API key from the official site, then choose between deleting unwanted comments outright and a fourteen-day probation folder. I even shared a small filter to drop into functions.php to extend that window to thirty days, with a line that already said it all: “we are never safe from a false positive.” Ten years on, I no longer protect blog comments but the forms on my own site, built on Pulsar, my PHP framework. And that 2016 instinct, keeping a doubtful message rather than losing a good one, has become the central rule of the anti-spam I ended up writing myself.
What Akismet taught me
Back then, I delegated everything. Akismet combined a dictionary of unwanted keywords, a set of rules and a blacklist, and sorted comments into pending or spam. For a blog, it worked well and I recommended it without reservation.
But delegating has a price. Every message travels to third-party servers for analysis, which, on a contact form where a prospect leaves their name and email, has become a real GDPR concern. And above all, the cost of a false positive has changed scale: a lost blog comment is a shame. A lost quote request is a client who will not come back. My old site eventually buckled under contact-form spam, and while rethinking the new one I set a starting constraint: filter seriously, without ever sending humans the bill.
Why no visible Captcha
The usual answer to spam is a Captcha. I ruled it out for three reasons.
Friction, first. Every step added before submission makes real visitors give up, while serious bots solve these puzzles or farm them out to click farms. You lose humans to slow down machines that are no longer slowed at all.
Accessibility, next. Spotting traffic lights in blurry thumbnails excludes people with low vision, and the audio alternatives are painful for everyone.
Privacy, last. The big-platform Captchas load third-party scripts, watch how the visitor behaves and raise consent questions I did not want to deal with. My site calls no external service for its forms: everything is self-hosted, nothing leaves.
A Captcha punishes the human for a problem created by bots. I wanted the opposite: checks that only bots notice.
Seven invisible layers instead of a wall
No single check is perfect on its own. Stacked together, they stop the bulk of automated spam without the visitor ever seeing a thing. Since the first version of this article, I have also changed my mind on one point: I used to stay at the level of the concept and hide the thresholds, out of caution. A filter that only holds because its parameters are secret is a fragile filter; this one holds even once described in full. So here it is, numbers included. Every submission passes through seven layers, in this order:
- the CSRF token, checked again at submission;
- rate limiting per IP address;
- the signed timing trap;
- the honeypot;
- the invisible proof-of-work challenge;
- content scoring, which never blocks;
- explicit GDPR consent.
The front door: CSRF token and rate limiting
Before spam even enters the picture, the CSRF token issued when the form is rendered is checked again at submission. It is a classic defense against forged requests, but it already turns away the scripts that post straight to the endpoint without ever loading the page.
Then comes rate limiting per IP address: ten attempts per hour and per form, over a fixed window, counted in the file cache shared by the PHP workers. Beyond that, the server answers 429 with a message localized in the visitor’s language. It is the only layer that refuses outright: a burst looks like no human, however hurried.
The silent traps: timing trap and honeypot
The timing trap. When the form is rendered, the server issues a hidden field carrying a timestamp signed with HMAC. A human takes seconds, often minutes, to read and fill in a form; a submission that comes back in under roughly three seconds is a bot. The site then answers “message sent” without sending anything. And if the signature is invalid or missing, the layer lets the submission through: fail-open, never a false positive. It only blocks on positive proof of automation, the other layers cover the rest.
The honeypot. A hidden field, invisible to humans, that bots fill in because they fill in everything. If it is filled, same treatment: silent acceptance, nothing sent. The bot does not know it was caught, so it learns nothing.
Its name, “ref_code”, means nothing, and that is deliberate. Chrome and password managers largely ignore autocomplete="off" and infer the type of a field from its label. A trap named “company website” ends up filled in by the browser of a real visitor, whose enquiry then goes to the bin without anyone knowing. Yes, I am publishing the field name: a spammer reading this article to dodge it would run into the six other layers.
The invisible challenge, a homegrown Turnstile
That left the well-equipped bot that runs JavaScript and fills the form properly. For that one, a self-hosted proof-of-work challenge, my homegrown equivalent of Turnstile: the server issues a signed single-use token, and the browser solves a small computation of about sixteen bits of difficulty in the background while the visitor writes their message. No click, no checkbox, no crosswalk pictures. Imperceptible to a human, expensive for a bot posting in bulk, with no API key and no third-party service. And if the browser does not run JavaScript, a fallback is in place: the missing answer does not block submission, the server-side layers take over. Graceful degradation applies to security too.
The zero lost lead rule
Sixth layer, content scoring: link density, text quality, duplicate detection. Each signal adds points to a score, and this is where the philosophy parts ways with classic filters: that score never rejects a submission. When the signals pile up, the message is still delivered, simply flagged for review in my inbox.
The pipeline therefore separates two families of checks. Positive proof of automation, like the honeypot, the timing trap or a burst, rejects. Content signals only score. A real client who pastes three links to their existing site, or writes two curt lines from their phone, gets through. I would rather read two spams than lose a client. That is exactly the logic of my thirty-day Akismet probation in 2016, pushed all the way: doubt always favors the message.
The seventh layer has nothing algorithmic about it: an explicit GDPR consent checkbox, the only part of this whole pipeline the visitor actually sees. After removing third-party scripts precisely for privacy reasons, the form itself had to be beyond reproach.
The honest scoreboard
An anti-spam pipeline is judged on evidence, not promises. Here are the four scenarios I verified in real conditions, none of them hidden:
- a real client fills in the form: the message is delivered;
- a simple bot fills in everything, honeypot included: silently accepted, never sent;
- a fast bot submits in under three seconds: the timing trap catches it, silently accepted, never sent;
- a burst from the same address: 429 after the tenth attempt within the hour.
What it looks like in practice
On the contact page and on the quote calculator, the visitor sees none of this machinery. No “I am not a robot” checkbox, no puzzle, no consent banner for a third-party script. The form fills in and sends, that is it. It is the same care I put into all my work as a web developer in Belgium: robustness must never be paid for in user friction.
If your own form is buckling under spam, my suggestion fits in one sentence: before adding a Captcha that will drive your visitors away, stack invisible checks and reserve rejection for proof of automation. In 2016, I ended my post by inviting you to comment and share. In 2026, the honest version is simpler: if the topic speaks to you, write to me through that very form. It will hold up.
