Skip to content
Rapcsány Krisztián Rapcsány Krisztián
hu
← All articles
2 min read

Bot protection for a contact form without a captcha

Laravel Security Forms

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.
BotCheck.php
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 →
Get a quote →