Honeypots
Honeypots turn attacker behaviour into your highest-confidence telemetry. No legitimate user ever requests /wp-login.php on a Laravel app or fills a form field that isn't visible — so every trigger is signal, never noise.
Route honeypots#
Decoy endpoints registered automatically from honeypots.routes.paths. A hit:
- emits a high-severity
honeypot_route_hitevent (MITRE T1595.003, Wordlist Scanning), - optionally auto-blocks the source IP (default: 24 hours), and
- returns an innocuous 404 so the attacker learns nothing.
'routes' => [
'enabled' => true,
'paths' => [
'.env',
'.git/config',
'wp-login.php',
'wp-admin',
'xmlrpc.php',
'phpmyadmin',
'backup.sql',
// ...
],
'response_status' => 404,
'tarpit_ms' => 0,
'auto_block' => true,
'block_minutes' => 1440,
],
The default list covers high-signal scanner targets: WordPress paths, exposed dotfiles, database dumps, RCE probe paths. Add decoys that fit your stack — and make sure a decoy never shadows a real route.
Parameterized honeypots#
Decoys can take route parameters, which is how you trap ID-guessing scripts on endpoints that look like real resources:
'paths' => [
'internal/photos/{id}',
['path' => 'api/v1/export/{file}', 'methods' => ['GET'], 'where' => ['file' => '.*\.sql']],
],
- String form — any URI, parameters allowed.
- Array form — adds
methods(restrict HTTP verbs) andwhere(regex constraints per parameter).
The probed values land in the event context, so the SOC sees exactly what the attacker tried:
{
"laravel": {
"context": {
"honeypot_pattern": "internal/photos/{id}",
"route_params": { "id": "42" },
"auto_blocked": true
}
}
}
The tarpit#
tarpit_ms delays honeypot responses by a random interval (half to full value). Against mass scanners this wastes attacker time at nearly zero cost to you — but note the sleep occupies a PHP worker, so keep values modest (e.g. 2000) or leave it off on small worker pools.
CSRF and honeypot routes#
Honeypot routes are deliberately registered without the web middleware group. If they had it, Laravel's CSRF layer would reject POST probes with a 419 before the hit was ever logged — and the whole point is to log it. Honeypots need no session state, so nothing is lost.
Form honeypot#
Two invisible traps for bots on public forms, packaged as a Blade component plus middleware.
Usage#
<form method="POST" action="{{ route('register') }}">
@csrf
<x-siem::honeypot />
...
</form>
Route::post('/register', [RegisteredUserController::class, 'store'])
->middleware('siem.honeypot');
Your controller doesn't change — the honeypot fields are just extra input it ignores.
How it catches bots#
The component renders an off-screen text input and an encrypted timestamp:
- Hidden field — invisible to humans (
aria-hidden, off-screen,tabindex="-1"), but bots parsing the HTML fill everything. Non-empty ⇒hidden_field_filled. - Timer — the encrypted render time is compared to the submit time. Faster than
min_seconds(default 2) ⇒submitted_too_fast. A forged or corrupted timestamp ⇒timer_tampered.
Each trigger emits a honeypot_form_triggered event with the reason, IP and user agent.
Reject vs silent mode#
respond |
Behaviour |
|---|---|
reject (default) |
422 response, like a validation failure |
silent |
Fake success (200 / redirect back) — the bot believes it worked and keeps wasting time instead of adapting |
Why CSRF doesn't make this redundant#
CSRF verifies that the client loaded your form first — not that a human filled it. Modern spam bots GET the page, extract the CSRF token with all other fields, and POST it back; that's a valid CSRF flow. Those bots are exactly what the honeypot catches. And a CSRF 419 is silent, while a honeypot catch is classified SIEM telemetry that can feed your blocklist. The layers stack: CSRF kills blind POSTs → honeypot kills token-aware scripts → abuse detection catches whatever persists.
Real-world notes#
- Deploy on unauthenticated forms that write data or send email: registration, contact, newsletter, comments, quote requests.
- Full-page caching freezes the timestamp — humans aren't false-positived (older is fine) but the min-time trap weakens on cached pages. The hidden field is unaffected.
- SPAs / JSON clients — the component is Blade-only; render the two fields yourself using the configured names, or rely on the hidden field alone.
- Change the field name per app (
SIEM_HONEYPOT_FIELD=fax_number) — anything that sounds like a real field bots would want to fill.