Back in March, a bot named RobertqueRy spammed a post about gRPC with an ad for online gambling in Bangladesh. I added a honeypot field, a CSRF token, an IP-based rate limit and a math captcha. In the conclusion of that article, I wrote a line I should have taken more seriously: "a dedicated bot that parses the HTML could bypass it." Five months later, two comments showed up on the article that explains how my comment system works. The target is almost funny. The payload wasn't: a car rental ad for Marrakech, hardcoded link included.
All four layers let the bot through. Not one crack, a clean bypass.
What actually failed
The honeypot is a hidden field only a bot fills in. Empty: the bot didn't fill it, it loaded the page and read the form correctly. The CSRF token changes every session and must match the one sent with the POST: present and valid, so the bot made a real GET request before its POST, like a normal browser. The rate limit allows 3 comments per IP every 10 minutes: only one comment posted, nothing to trigger. That left the captcha, my last line of defense, a single-digit addition displayed in plain text on the page.
That's where my March mistake came back to bite me. The captcha wasn't rendered as an image, wasn't obfuscated in any way. It was just two numbers and a + sign, in plain text, in the HTML:
<label for="bk-captcha">Anti-spam check: 6 + 1 = ?</label>
A bot that downloads the page and greps the digits doesn't even need to pretend it understands addition. It extracts the numbers, computes the sum, and posts. That's exactly the test I ended up writing myself to verify the fix, without thinking twice about it: a script that loads the page, pulls the CSRF token and the two captcha operands out with two lines of grep, computes the sum, and posts. Three minutes of bash on a Friday morning. If I can automate solving my own captcha in three minutes, a real spam bot automated it a long time ago.
The real problem: I was checking the wrong thing
Honeypot, CSRF, rate limit, captcha: all four layers answer the same question, "did a human fill out this form normally?" The bot that spammed me answered yes to all four. It loaded the page, left the trap empty, grabbed the right token, respected the rate limit, and solved a single-digit sum. From its perspective, it was a human visitor filling out a form once. What none of these layers ever asks is what the comment actually contains.
That's the gap. A whole system for verifying who the visitor is, and zero lines looking at what they're trying to publish. Two comments pushing car rentals in Marrakech and Cesme sailed through four security layers without a single one reading their content.
The real filter: judge the content, not the visitor
The site runs three near-identical comment forms (blog, book reviews, skill pages), each with its own handler file, deliberately duplicated rather than centralized in a shared lib: three small independent PHP files are cheaper to reason about than one shared abstraction. I added the same block to all three. Two rules:
// Anti-spam: external links and common spam-comment keywords
$linkText = $author . ' ' . $content;
$hasExternalLink = (bool)preg_match('/<a\b/i', $linkText);
if (!$hasExternalLink && preg_match_all('/https?:\/\/\S+|www\.\S+/i', $linkText, $urlMatches)) {
foreach ($urlMatches[0] as $url) {
if (!preg_match('/^(https?:\/\/)?(www\.)?web-developpeur\.com/i', $url)) {
$hasExternalLink = true;
break;
}
}
}
if ($hasExternalLink) {
// reject: external link
}
$spamWords = ['rent a car', 'car rental', 'car hire', 'escort', 'casino', 'viagra', 'cialis', 'forex trading', 'crypto signals', 'seo services', 'backlink', 'loan offer'];
foreach ($spamWords as $spamWord) {
if (stripos($content, $spamWord) !== false || stripos($author, $spamWord) !== false) {
// reject: spam keyword
}
}
No external links, none of the keywords that show up in 90% of comment spam. It's not elegant, it's not machine learning, it's a list of patterns describing the spam I actually received. And unlike the captcha, it asks nothing of the visitor: someone writing a real comment almost never needs to paste a link or say "rent a car."
The trap of a filter that's too strict
Almost never, not never. One reader, Domenico, once flagged a broken page by pasting the failing URL into a comment. A "zero links" filter would reject that bug report exactly the way it rejects a car rental ad, and I'd only find out if Domenico pushed back. That's the danger of a filter that's too broad: it doesn't warn you when it's wrong, it just silently rejects.
The fix lives in the same regex: links to web-developpeur.com itself stay allowed. The filter blocks whatever pulls the reader off the site, not whatever helps them report a problem on it. The distinction is one line of code, but I only thought of it because I had the real case in front of me.
Testing the fix with the bot's own trick
A passing php -l proves nothing about the form's actual behavior. To verify, I replayed exactly what a half-competent bot does: load the live page, pull the CSRF token and captcha operands out of it, compute the sum, and post a real comment with curl. Two runs: one comment with an external link, one clean.
PAGE=$(curl -s -c cookies.txt "https://www.web-developpeur.com/blog/deuxieme-spam-commentaires-liens-php")
CSRF=$(echo "$PAGE" | grep -oE 'name="csrf_token" value="[^"]*"' | sed -E 's/.*value="([^"]*)"/\1/')
# ... same captcha-reading move a bot would make
curl -s -b cookies.txt -D - "https://www.web-developpeur.com/blog/comment-handler" \
--data-urlencode "content=Check my site https://spam-example.test for deals" \
--data-urlencode "csrf_token=$CSRF" --data-urlencode "captcha=$SUM" | grep Location
# -> comment_error=Only+links+to+this+site+are+allowed...
The external link got rejected, the clean comment got published. That second test comment landed in the real production comment file, exactly like the bot's comment five months earlier. I deleted it by hand over FTP within the minute, before it sat there in front of real readers.
Takeaway
A plain-text captcha doesn't test whether you're human, it tests whether you can parse a DOM. I spent five months believing my four layers protected against spam, when they protected against one specific kind of bot: the one that posts blind without reading the page. Against a bot that reads it, they're just friction.
Real protection was never about proving a visitor is human. It's about looking at what they're trying to publish. That sounds obvious written down. It wasn't, until two car rental ads for Morocco reminded me.
📚 The full Web Security course, free and interactive
Telling real security from the appearance of security is the core of the Web Security course: OWASP, authentication, CSRF, access control, with runnable attack labs right in the page.