solution-forest / filament-loginguard
Enterprise-grade login security for Filament: brute-force lockouts with escalation, self-service unlock, and active session management.
Package info
github.com/solutionforest/filament-loginguard
pkg:composer/solution-forest/filament-loginguard
Fund package maintenance!
Requires
- php: ^8.3
- filament/filament: ^5.8.2
- spatie/laravel-package-tools: ^1.15.0
- whichbrowser/parser: ^2.1
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/breeze: ^2.4
- laravel/fortify: ^1.39
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-livewire: ^4.0
- rector/rector: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Filament LoginGuard
Enterprise-grade login security for Filament and Laravel β persistent brute-force protection, escalating IP and email lockouts, and active-session management.
Note
Filament already throttles its login page at 5 attempts per minute per IP. This package adds the missing lockout layer on top of that: persistent tracking, escalating bans, per-email protection and an admin UI.
Features
- π Brute-Force Protection
- π¦ Persistent per-IP/email attempt tracking
- π Escalating IP & email lockouts
- πΈοΈ Cross-email (per-IP) and cross-IP (per-email) aggregation
- π§ Attempt decay window
- π― IP & email whitelists
- π Self-service unlock link (anti-DoS) mailed to the blocked address
- π Administrator lockout notifications (cooldown + queue support)
- π₯ Session Management
- π₯οΈ Active-session listing with "last active" state
- π New-device detection with optional email alert
- π Concurrent-session limits with oldest-first eviction
- π One-click session revoke
- π± My Devices β self-service device list with sign-out, "This isn't me" and device forgetting (cookie-token device identity)
- π§° Filament Management Interface
- π Login Attempts page β inspect & unblock recorded attempts
- π Failed-attempts / lockout stats widget
- π Failure-trend & top-attacked charts (7/30-day trend, Top-10 emails & IPs)
- π€ CSV export of attempts & sessions (via Filament's export system)
- π‘οΈ Gate-based per-page authorization
Compatibility
| Requirement | Version |
|---|---|
| PHP | 8.3+ |
| Laravel | 11, 12 or 13 |
| Filament | 5.x (β₯ 5.8.2, Livewire 4) |
Earlier 5.x releases have known security advisories; require at least Filament 5.8.2.
Table of Contents
- Features
- Compatibility
- Installation
- How it works
- Session management
- Configuration
- Maintenance
- Roadmap
- Testing
- Changelog
- Contributing
- Security Vulnerabilities
- Credits
- License
Installation
1. Install the Package
You can install the package via composer:
composer require solution-forest/filament-loginguard
2. Publish the Config and Migrate
Migrations are loaded and run automatically (runsMigrations()), so you only need to publish the config file and run migrate:
php artisan vendor:publish --tag="filament-loginguard-config"
php artisan migrate
The interactive install command publishes the config file for you and reminds you to star the repo:
php artisan filament-loginguard:install
Optionally, you can publish the views and translations using:
php artisan vendor:publish --tag="filament-loginguard-views" php artisan vendor:publish --tag="filament-loginguard-translations"
CSV export prerequisites
The CSV export buttons are built on Filament's export system, which needs its own database tables. Fresh apps may be missing them β run:
php artisan make:queue-batches-table php artisan make:notifications-table php artisan vendor:publish --tag=filament-actions-migrations php artisan migrate
Without these, the first export attempt fails with a "table not found" error (exports, job_batches or notifications). Exports also run through the queue, so a queue worker (or the sync driver) must be available.
3. Register the Admin Pages
To enable the admin pages (Login Attempts: view / unblock recorded attempts; User Sessions: list / revoke active sessions), register the plugin in your panel provider, e.g. app/Providers/Filament/AdminPanelProvider.php:
use SolutionForest\FilamentLoginGuard\FilamentLoginGuardPlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugin(FilamentLoginGuardPlugin::make()); }
The core protection works without registering the plugin β only the admin pages need it. Each page can also be disabled independently via pages.attempts.enabled / pages.sessions.enabled.
How it works
The package hooks into Laravel's core auth events rather than any specific UI, so it protects every login flow that goes through Auth::attempt():
- Native Laravel auth β the Laravel Starter Kits, Breeze, and Fortify all authenticate through
Auth::attempt(), so their login forms are protected out of the box. - Filament β the built-in login page (and the Filament Starter Kit, which extends it) calls
attemptWhen(), which fires the same events. - Custom controllers β anything that calls
Auth::attempt()directly works too.
Social login (OAuth/Socialite) has no password to brute-force, so it doesn't fire
Attempting/Failed; it still firesLogin, so success tracking and session management apply.
| Event | Action |
|---|---|
Illuminate\Auth\Events\Attempting |
If the IP or email is currently locked out, a ValidationException with the lockout message is thrown before any credential work happens. |
Illuminate\Auth\Events\Failed |
The failure is recorded for the (IP, email) pair. When the threshold is reached, the IP and/or email is locked out and admins are notified by email. |
Illuminate\Auth\Events\Login |
A successful login resets the failed-attempt counter for that (IP, email) pair and releases the email-scoped lock. IP-scoped locks remain in force so one valid login cannot unblock a shared or attacking IP. |
Lockout semantics:
- Rows are stored per (IP, email) pair in the
filament_loginguard_attemptstable. - With
tracking.per_ipenabled, the sum of attempts across all emails from one IP triggers a lockout of that IP β rotating emails doesn't help attackers.tracking.per_emaildoes the same across IPs for one email. - Lock durations escalate: the first lockout lasts
lockout.initial_minutes; the 2nd, 3rd and subsequent lockouts uselockout.escalation_hours(e.g.[24, 72, 168]= 1 day, 3 days, 7 days+). The last value repeats for every further lockout. Escalation history is stored in its own append-only table, so cleaning up old attempt rows never resets the ladder. - Attempts are counted in a fixed window: failures only accumulate while they fall inside the same
attempts_window_minuteswindow; the first failure after the window expires restarts the counter at zero. A slow drip just under the window length never reaches the threshold. - A lock is never extended by further attempts while it is active; a successful login resets the pair's counter and releases the email lock (forgiving the legitimate owner without unblocking the shared IP).
- The lockout message is rendered in the Filament login form (
data.emailerror key) and as a standardemailvalidation error in non-Filament forms (redirect back for web requests, 422 for JSON). - Localhost (
127.0.0.1,::1) is whitelisted by default. - Behind a proxy/CDN? Set
lockout.trusted_proxiesto your proxy IPs or CIDR ranges. When the immediate request IP matches one, the real client IP is read fromX-Forwarded-For(walking right-to-left past trusted proxies, so clients cannot spoof it). Laravel's own TrustProxies middleware may already rewriterequest()->ip()β prefer configuring it there if you use the standard mechanism. - A
LoginLockedOutevent is dispatched on every lockout β listen for it to wire up custom alerting (Slack, webhooks, etc.) alongside or instead of the built-in email notification.
Warning
Email lockouts can be abused as a denial-of-service vector. An attacker who knows (or guesses) an account's email can deliberately fail logins to lock that email out β the legitimate owner is then blocked too. Mitigations:
- Enable self-service unlock (
lockout.notifications.self_unlock.enabled): the lockout email sent to the blocked address carries a signed, single-use "unlock now" link (a confirmation page, then the unlock β the URL contains only an opaque token, never the email). The real owner can clear the lock immediately. Attempt counters are kept, so repeated lockouts still escalate. IP locks are never lifted this way. - Whitelist critical accounts via
lockout.whitelist.emailsso they can always log in. - Keep
lockout.initial_minutesmoderate and review theescalation_hoursladder. - If you run behind a proxy/CDN, configure
lockout.trusted_proxies(below) so lockouts key on the real client IP β otherwise all traffic appears to come from the proxy IP and a single attacker can lock out everyone.
The page also includes visualization and reporting tools:
- Charts (
pages.attempts.charts): a daily failed-attempts trend with a 7/30-day filter plus Top-10 leaderboards for attacked emails and source IPs β attack waves are obvious at a glance. - CSV export (
pages.attempts.export): export the table (respecting your filters) via Filament's export system. Exports run through the queue, so a queue worker must be available in production.
Session management
Requires SESSION_DRIVER=database. The User Sessions admin page lists every active session with a human-readable "last active" state (Laravel updates last_activity on every request, including Livewire clicks) and a one-click Revoke.
-
New-device detection: each login's browser+platform is fingerprinted (
sessions.new_device); a session is flagged "New" on the page when its fingerprint was first seen withinwindow_hours, and an optional email notifies the account owner the first time a device is seen. -
Concurrent session limits: set
sessions.concurrent_limitto cap sessions per user β the oldest sessions are evicted to make room when a new login would exceed the limit. -
CSV export (
pages.sessions.export): export the active-session list as CSV (via Filament's export system). -
Closing the browser without logging out (or backgrounding the tab) simply stops updating
last_activity, so the session ages out naturally and expires aftersession.lifetime; sweep expired rows with thefilament-loginguard:cleanup-sessionscommand. -
Closing the browser without logging out (or backgrounding the tab) simply stops updating
last_activity, so the session ages out naturally and expires aftersession.lifetime; sweep expired rows with thefilament-loginguard:cleanup-sessionscommand.
My Devices (self-service account security)
Every panel user gets a My Devices entry in the user menu (no admin permission required β the query is always scoped server-side to the authenticated user). Devices are grouped by live-session state:
- This device β pinned on top, marked "This device", with no destructive actions.
- Other signed-in devices β devices with live sessions, each with Sign out (delete that device's sessions, keep recognition) and "This isn't me" (a security incident: revoke all device sessions AND the identity β the cookie token can never silently become recognized again).
- Recently signed out β a collapsible section of devices with no live session, each with Forget device (normal removal; the next sign-in from that browser is treated as a new device).
Each row shows a type-aware icon (desktop / mobile / tablet, falling back to a terminal icon for scripts and unrecognized clients), the device name, "Last active Β· IP", and a View details slide-over (client, browser/OS, first seen, last active, last IP, active-session count and the full user agent). A Sign out other sessions header action deletes all other sessions while keeping the current one and all device recognitions β a known device is never shown as online just because it was seen before.
Device identities are powered by an opaque cookie token: the browser holds a random token, the database only stores its SHA-256 hash, and identities are scoped per guard + user model + user identifier β so shared browsers, UUID/ULID users and multi-guard panels never collide. Devices beyond devices.retention_days with no active sessions are removed by filament-loginguard:cleanup-devices.
Configuration
This is the contents of the published config file (config/filament-loginguard.php), grouped into five sections: lockout (brute-force protection behavior), sessions (active-session tracking behavior), devices (device identity for My Devices), maintenance (optional auto-scheduling), and pages (Filament admin page wiring for both features):
return [ 'lockout' => [ 'enabled' => true, // master switch 'max_attempts' => 10, // failures allowed inside the window before a lockout 'initial_minutes' => 15, // duration of the first lockout 'escalation_hours' => [24, 72, 168], // 2nd, 3rd, 4th+ lockout durations; last value repeats 'attempts_window_minutes' => 30, // decay + aggregate counting window 'trusted_proxies' => [], // proxy/CDN IPs or CIDRs; X-Forwarded-For is honored for these 'tracking' => [ 'per_ip' => true, // aggregate attempts across emails per IP 'per_email' => true, // aggregate attempts across IPs per email 'guards' => [], // restrict to specific guards, e.g. ['web']; empty = all ], 'whitelist' => [ 'ips' => ['127.0.0.1', '::1'], 'emails' => [], ], 'notifications' => [ 'enabled' => true, 'mail' => [ 'to' => [], // admin addresses; empty = no notifications 'cooldown_minutes' => 60, // at most one notification per IP per window 'queue' => false, // false = sync; queue name string = queued ], 'self_unlock' => [ 'enabled' => false, // mail a signed, single-use unlock link to the blocked address 'link_ttl_minutes' => 60, // how long the unlock link stays valid 'queue' => false, // false = sync; queue name string = queued ], ], ], // Active user sessions (requires SESSION_DRIVER=database). 'sessions' => [ 'enabled' => true, // master switch for session security features 'table' => 'sessions', 'online_threshold_seconds' => 60, // "online now" cutoff 'user_model' => null, // null = auth.providers.users.model 'concurrent_limit' => null, // max concurrent sessions per user; null = unlimited 'new_device' => [ 'enabled' => true, // @deprecated v0.7 β see devices.* 'window_hours' => 24, // sessions first seen within this window are flagged "New" 'notifications' => [ 'enabled' => false, // emails the account owner the first time a device is seen 'mail' => [ 'queue' => false, ], ], ], ], // Device identity (My Devices): opaque cookie tokens, hashed in the DB. 'devices' => [ 'enabled' => true, // master switch for the device identity layer 'cookie' => [ 'name' => 'filament_loginguard_device', 'lifetime_days' => 365, 'same_site' => 'lax', ], 'retention_days' => 90, // drop devices with no sessions after this many days (0 = keep) 'max_devices_per_user' => 20, // hard cap of device identities per account (0 = unlimited) 'notifications' => [ 'new_device' => false, // email the account owner on a new device sign-in 'mail' => [ 'queue' => false, ], ], ], 'maintenance' => [ 'cleanup_attempts' => [ // delete stale attempt records 'enabled' => false, // false = schedule it yourself in routes/console.php 'expression' => '0 0 * * *', // cron expression (default: daily at midnight) ], 'cleanup_sessions' => [ // delete expired session rows (needs SESSION_DRIVER=database) 'enabled' => false, 'expression' => '0 * * * *', // cron expression (default: hourly) ], ], 'pages' => [ 'attempts' => [ 'enabled' => true, 'slug' => 'login-guard', 'cluster' => null, // optional Filament Cluster class-string to nest the page under 'navigation_label' => null, // null falls back to translations 'navigation_icon' => 'heroicon-o-shield-exclamation', 'navigation_group' => null, 'navigation_sort' => null, 'authorize' => null, // gates everything; the finer keys below win when set 'authorize_view' => null, // viewing the page 'authorize_unblock' => null, // unblock / bulk-unblock actions 'stats_widget' => true, // show the failed-attempts / lockout stats widget 'charts' => true, // show the failure-trend & top-attacked charts 'export' => true, // allow CSV export of the attempts table ], 'sessions' => [ 'enabled' => true, // admin page only; session security uses sessions.enabled 'slug' => 'user-sessions', 'cluster' => null, 'navigation_label' => null, 'navigation_icon' => 'heroicon-o-computer-desktop', 'navigation_group' => null, 'navigation_sort' => null, 'authorize' => null, 'authorize_view' => null, // viewing the page 'authorize_revoke' => null, // revoke / bulk-revoke actions 'export' => true, // allow CSV export of the sessions table ], // Self-service account security (no admin permission needed). 'my_devices' => [ 'enabled' => true, 'slug' => 'my-devices', 'user_menu' => true, // show "My Devices" in the user menu 'navigation' => false, // also list it in the panel navigation 'navigation_label' => null, 'navigation_icon' => 'heroicon-o-device-phone-mobile', 'navigation_group' => null, 'navigation_sort' => null, ], ], ];
Warning
Both admin pages are visible to any authenticated panel user by default. Restrict them with the authorize option, which accepts a permission/ability name checked via $user->can(...). For finer control, use the split keys: authorize_view gates viewing the page and authorize_unblock / authorize_revoke gate the security actions β e.g. grant reviewers view-only access and let operators perform unblocks/revokes.
With spatie/laravel-permission (e.g. when using Filament Shield), the string is a permission name β just create the permission and assign it to a role; no Gate is required:
// config/filament-loginguard.php 'pages' => [ 'attempts' => ['authorize' => 'view-filament-loginguard', /* ... */], 'sessions' => ['authorize' => 'view-filament-loginguard', /* ... */], ],
Without spatie, define a Gate for the ability in a service provider:
// app/Providers/AppServiceProvider.php Gate::define('view-filament-loginguard', fn (User $user) => $user->can('access-admin-settings'));
Maintenance
Delete stale, expired attempt records (outside the decay window with no active lock):
php artisan filament-loginguard:cleanup-attempts
php artisan filament-loginguard:cleanup-attempts --all # delete every record
Delete expired session rows (Laravel's session garbage collection is probabilistic, so rows from closed browsers can linger):
php artisan filament-loginguard:cleanup-sessions
php artisan filament-loginguard:cleanup-sessions --all # delete every session
Schedule both in routes/console.php for automatic upkeep:
use Illuminate\Support\Facades\Schedule; Schedule::command('filament-loginguard:cleanup-attempts')->daily(); Schedule::command('filament-loginguard:cleanup-sessions')->hourly();
Alternatively, set maintenance.cleanup_attempts.enabled / maintenance.cleanup_sessions.enabled to true in the config and the package registers them for you using each command's expression cron value (cleanup-sessions only when SESSION_DRIVER=database). Prefer scheduling manually when you need a custom frequency, timezone, or onOneServer().
Roadmap
Planned features, roughly in the order they are likely to land. Suggestions and PRs welcome β open an issue to discuss.
- Progressive response & CAPTCHA β soften the cliff between "allowed" and "locked out". After a configurable number of failures, the login form requires a CAPTCHA (Cloudflare Turnstile or hCaptcha) instead of blocking outright; only repeated failures beyond a second threshold trigger a lockout. Shared-IP environments (offices behind NAT) stop being collateral damage, while bots fail the challenge automatically.
- Risk-based login (adaptive authentication) β score every login from signals the package already collects (unseen device fingerprint, new IP range, unusual time of day, recent failure counts) and respond proportionally: allow, step-up verification (email code), or block and alert the account owner. An attacker with a stolen password is challenged on an unfamiliar device, while the legitimate owner logs in without noticing anything.
- My devices (self-service account activity) β a per-user panel section (like Google's "Your devices") listing the user's own signed-in devices β browser, OS, IP, last active β with a "View details" slide-over, one-click "This isn't me" revoke and a "sign out other sessions" button. Users can react to suspicious logins themselves instead of waiting for an admin. (shipped in v0.7.0 β powered by opaque device-token identities)
- Geo / ASN blocking β resolve the login source against an IP geolocation database (e.g. MaxMind GeoLite2) and optionally block or flag traffic by country or ASN. Blocking datacenter ASNs is a very cheap way to cut most scripted attacks without touching residential users; flagged attempts get a country badge on the admin pages.
- Charts widget β visualize the lockout stats that the numeric widget already tracks: a daily failed-attempts trend for the last 7/30 days and Top-10 leaderboards (attacked emails, source IPs, devices). Makes attack waves obvious at a glance. (shipped in v0.6.0)
- Slack / Telegram / Webhook notifications β deliver lockout alerts beyond email via Laravel notification channels β a Slack channel is where on-call engineers actually look. Configured per channel (webhook URL, queue) alongside the existing mail notifications.
- Cloudflare integration β push lockouts to the edge: when an IP is locked out, automatically create a block rule in your Cloudflare zone via the API (and optionally remove it on unlock). Attack traffic is dropped at the CDN instead of ever reaching Laravel, which changes the game under volumetric attacks.
- CSV export β export filtered Login Attempts and User Sessions as CSV from the admin tables for compliance audits (ISO 27001 / SOC 2), incident forensics, and management reports. (shipped in v0.6.0)
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see License File for more information.

