DEV3 Technologies home

Honeypot and time-trap middleware

Block simple form bots with a hidden field and an encrypted render timestamp, rejecting submissions that are filled in too fast.

LaravelMiddlewarespamhoneypotmiddlewareforms

Code

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

class SpamTrap
{
    /** Humans need a few seconds to fill a form; bots post instantly. */
    private const MIN_SECONDS = 3;
    private const MAX_SECONDS = 7200;

    public function handle(Request $request, Closure $next): Response
    {
        // 1. Honeypot: a field hidden with CSS that real visitors never fill.
        if ($request->filled('website_url')) {
            return $this->fake($request);
        }

        // 2. Time trap: the token is the encrypted moment the form was rendered.
        try {
            $age = time() - (int) Crypt::decryptString((string) $request->input('_rendered'));
        } catch (Throwable) {
            return $this->fake($request);
        }

        if ($age < self::MIN_SECONDS || $age > self::MAX_SECONDS) {
            return $this->fake($request);
        }

        return $next($request);
    }

    /** Look successful so the bot does not learn which check it failed. */
    private function fake(Request $request): Response
    {
        return $request->expectsJson()
            ? response()->json(['ok' => true])
            : back()->with('status', 'Thanks, we received your message.');
    }
}

How it works

What it solves

Contact forms and public surveys attract bots that fill every input and post immediately. Visible CAPTCHAs hurt conversion, so this uses two invisible checks that stop the large majority of unsophisticated bots without bothering real people.

How it works

The Blade component renders a text input named website_url inside a container moved off-screen, with tabindex="-1" and aria-hidden, so keyboard and screen reader users never meet it. Bots that fill every field give themselves away. It also renders a hidden _rendered field holding the render time, encrypted with the app key.

The middleware first rejects any request where the honeypot has a value. Then it decrypts the timestamp and computes the age. A tampered or missing token fails to decrypt and is rejected; an age under three seconds means the form was posted faster than a person could type; an age over two hours means a stale page. Because the timestamp is encrypted, a bot cannot forge an older one.

Rejected requests receive a fake success response, so the sender gets no signal to adapt to.

Adapting it

  • Rename website_url per form, or generate it from a seed, to defeat bots that learn the name.
  • Log rejected attempts with Log::info to measure how much spam you stop.
  • Apply with Route::post('/contact', ...)->middleware(SpamTrap::class).

Pitfalls

  • Password managers may autofill a field called website; keep an unusual name.
  • Cached form pages freeze the timestamp, so never cache pages that include this component.

How to use it

  1. Save the Blade file as resources/views/components/spam-trap.blade.php.
  2. Put SpamTrap.php in app/Http/Middleware/.
  3. Add <x-spam-trap /> inside each public form, next to @csrf.
  4. Attach the middleware to the POST route: ->middleware(\App\Http\Middleware\SpamTrap::class).
  5. Do not full-page cache the form route.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets