Bot protection for a contact form without a captcha
I did not want to burden the contact form on my own site with a captcha. Someone asking for a quote should not have to hunt for traffic lights in pictures. So I built several invisible layers on top of each other, and I only switch on an external service (Turnstile) if those turn out to be too little.
The five layers
- A honeypot. Bots fill in a hidden field, people never see it.
- A timestamp. The form gets an encrypted timestamp. If it comes back in under four seconds, a person did not fill it in.
- A human signal. JavaScript fills in a field after the first click or keypress. If it is missing, I do not shut anyone out, I just expect more time.
- Content. More than three links in a message is almost always spam.
- The client. An empty or robot-like user agent does not get through.
public static function reason(Request $request): ?string
{
if ($request->filled('website')) { // honeypot: a hidden field only bots fill in
return 'honeypot';
}
$ua = strtolower((string) $request->userAgent());
if ($ua === '' || preg_match('/bot|crawl|spider|curl|wget|python|headless/', $ua)) {
return 'ua';
}
try {
$issued = (int) Crypt::decryptString((string) $request->input('_ts'));
} catch (\Throwable) {
return 'token';
}
$elapsed = time() - $issued;
if ($elapsed < 4) { // faster than a person can type
return 'too-fast';
}
// Set by JavaScript on the first real interaction. Without it, expect a slower visitor.
$humanSignal = preg_match('/^h[a-z0-9]{3,20}$/', (string) $request->input('_js')) === 1;
if (! $humanSignal && $elapsed < 20) {
return 'no-js';
}
if (preg_match_all('~https?://|www\.~i', (string) $request->input('message')) > 2) {
return 'links';
}
return null;
}
An excerpt of real code, anonymised.
The bot should not know it failed
When a check fails, I show the same success screen as if everything were fine. The bot gets no feedback it could learn from. Meanwhile I can see in the log (which holds no personal data) which layer caught something.
Do not forget the real visitor
The most important rule is that protection must not get in the way of a real person. A visitor without JavaScript is not shut out either, I just expect more time from them, twenty seconds. The form is valid for a day, and if it expires, the visitor is asked to reload, instead of getting a silent error.
Working on a similar problem?
If you are stuck, or want a second look at your solution, write to me. I am happy to look.
Write to me →More articles
A Laravel gotcha: the throttle counter is shared
throttle:5,1 and throttle:120,1 use the same counter per IP. That is how my analytics beacon used up the form's allowance.
Read →A strict CSP in Laravel, with Vite
How I allow my own inline scripts with a one-time identifier, and block everything else.
Read →