novwyatt / laravel-login-guard
Brute-force login protection for Laravel: progressive lockouts per IP and username, allow and block lists, events, alerts by mail, Slack or Telegram, and an audit log.
Requires
- php: ^8.3
- illuminate/auth: ^12.0|^13.0
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/notifications: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0|^5.0
- pestphp/pest-plugin-laravel: ^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
English · Tiếng Việt
Brute-force login protection for Laravel 12 and 13. It counts failed logins per username and per IP address, locks them with growing durations, honours allow and block lists, fires events, sends alerts by mail, Slack or Telegram, and keeps an audit log.
It hooks into Laravel's own authentication events, so every Auth::attempt() is covered: Fortify, Breeze, Jetstream
or a hand-written controller, without changing your login code.
Features
- Failed attempts counted per username (default 5 within 15 minutes) and per IP address (default 20, because many people can share an address). Usernames are matched case-insensitively.
- Progressive lockouts: 1, 2, 4, 8 minutes… up to a day. The level resets after a quiet day.
- The attempt that triggers a lockout already shows the lockout message, in the user's language (English and Vietnamese included).
- Locked attempts are refused before the password is checked, inside Laravel's timebox, so they take as long as a wrong password.
- IP allow list (never locked by IP) and block list (never allowed to log in), with CIDR ranges and IPv6.
LoginLockedandSuspiciousActivityevents. Suspicious activity covers logins from blocked addresses and one IP address trying many different usernames (credential stuffing).- Alerts by mail, Slack incoming webhook or Telegram bot, at most one per username or IP address per hour. A failing alert channel is reported and never breaks the login.
- Audit log of failed logins, lockouts, refused attempts, unlocks and suspicious activity, pruned by
model:prune. php artisan login-guard:listandlogin-guard:unlock.
Requirements
- PHP 8.3 or newer
- Laravel 12 or 13
- A cache store that persists between requests for the counters (database, Redis, Memcached, file…)
Installation
composer require novwyatt/laravel-login-guard php artisan vendor:publish --tag=login-guard-migrations php artisan migrate
That is all: the service provider is discovered automatically and starts guarding logins.
Optionally publish the config file or the translations:
php artisan vendor:publish --tag=login-guard-config php artisan vendor:publish --tag=login-guard-lang
Behind a proxy or a load balancer
The IP address comes from $request->ip(). Behind a proxy (Cloudflare, a load balancer, a PaaS edge) every request
may seem to come from the proxy, and the whole site would share one IP counter. Configure
trusted proxies first, or raise
LOGIN_GUARD_IP_MAX_ATTEMPTS.
Dual-stack servers and some proxies report IPv4 clients as IPv4-mapped IPv6 addresses (::ffff:203.0.113.10). The
guard turns those into plain IPv4 before counting, locking, logging and checking the lists, and writes IPv6 addresses
in their short lower-case form, so one client always has one counter. Write IPv4 ranges in the allow and block lists
in IPv4 form (203.0.113.0/24).
Configuration
Every option can be set from .env:
| Variable | Default | Meaning |
|---|---|---|
LOGIN_GUARD_ENABLED |
true |
Turn the guard off without uninstalling it |
LOGIN_GUARD_USERNAME_FIELD |
email |
Credential key holding the login name; errors are attached to it |
LOGIN_GUARD_CACHE_STORE |
cache.limiter, then default |
Cache store for the counters (must not be array) |
LOGIN_GUARD_USERNAME_MAX_ATTEMPTS / _DECAY_SECONDS |
5 / 900 |
Failures per username within the window |
LOGIN_GUARD_IP_MAX_ATTEMPTS / _DECAY_SECONDS |
20 / 900 |
Failures per IP address within the window |
LOGIN_GUARD_LOCKOUT_SECONDS |
60 |
First lockout |
LOGIN_GUARD_LOCKOUT_MULTIPLIER |
2 |
Each next lockout is this many times longer |
LOGIN_GUARD_LOCKOUT_MAX_SECONDS |
86400 |
Longest lockout |
LOGIN_GUARD_LOCKOUT_RESET_AFTER_SECONDS |
86400 |
Quiet time after which the level starts again from 1 |
LOGIN_GUARD_ALLOWLIST |
Comma separated IPs or CIDR ranges never locked by IP | |
LOGIN_GUARD_BLOCKLIST |
Comma separated IPs or CIDR ranges that cannot log in | |
LOGIN_GUARD_SUSPICIOUS_DISTINCT_USERNAMES |
5 |
Different usernames from one IP that count as suspicious (0 = off) |
LOGIN_GUARD_LOG_ENABLED |
true |
Write the audit log |
LOGIN_GUARD_LOG_SUCCESSFUL_LOGINS |
false |
Also log successful logins |
LOGIN_GUARD_LOG_PRUNE_AFTER_DAYS |
90 |
Age at which model:prune deletes log rows |
LOGIN_GUARD_ALERT_MAIL |
Comma separated addresses to alert | |
LOGIN_GUARD_ALERT_SLACK_WEBHOOK_URL |
Slack incoming webhook | |
LOGIN_GUARD_ALERT_TELEGRAM_BOT_TOKEN / _CHAT_ID |
Telegram bot and chat to alert | |
LOGIN_GUARD_ALERT_THROTTLE_SECONDS |
3600 |
At most one alert per username or IP within this time |
A setting with the wrong type (say, a non-numeric attempt limit) falls back to its default instead of breaking logins.
How it works
| Laravel event | What the guard does |
|---|---|
Attempting |
Refuses the attempt (HTTP 429, or a redirect back with the error on the form) if the IP address is blocked, or the username or the IP address is locked |
Failed |
Counts the failure for the username and the IP address; at the limit, locks it and shows the lockout message |
Login |
Clears the username counter. The IP counter is kept, so an attacker cannot reset it by logging into an account of their own |
Counters live in the cache; lockouts live in the login_guard_lockouts table, so they survive a cache flush and can
be listed. Password confirmation (Auth::validate()) does not fire these events and is not counted.
The refusal is a ValidationException with status 429 on the username field, which Laravel and Fortify render as
usual: JSON {"message": …, "errors": {"email": […]}} for API calls, a redirect back with the error otherwise.
Fortify
Fortify's own login throttle (EnsureLoginIsNotThrottled, 5 attempts per minute per email and IP) keeps running in
front of the guard. To make the guard the only limit, give Fortify a login pipeline without it:
// config/fortify.php use Laravel\Fortify\Actions\AttemptToAuthenticate; use Laravel\Fortify\Actions\CanonicalizeUsername; use Laravel\Fortify\Actions\PrepareAuthenticatedSession; 'pipelines' => [ 'login' => [ CanonicalizeUsername::class, // if you use 'lowercase_usernames' => true AttemptToAuthenticate::class, PrepareAuthenticatedSession::class, ], ],
With two-factor authentication on, add Laravel\Fortify\Contracts\RedirectsIfTwoFactorAuthenticatable::class before
AttemptToAuthenticate::class, as Fortify's default pipeline does.
Usage
Commands
php artisan login-guard:list # what is locked now php artisan login-guard:list --all # include expired lockouts that still count towards the next level php artisan login-guard:unlock alice@example.com php artisan login-guard:unlock 203.0.113.10 --ip php artisan login-guard:unlock --all
Unlocking also clears the failure counters and resets the lockout level.
Facade
use NovWyatt\LoginGuard\Enums\Subject; use NovWyatt\LoginGuard\Facades\LoginGuard; LoginGuard::isLocked('alice@example.com', $request->ip()); // bool LoginGuard::lockoutSeconds('alice@example.com', $request->ip()); // seconds left, 0 when free LoginGuard::remainingAttempts('alice@example.com', $request->ip()); LoginGuard::lock(Subject::Ip, '203.0.113.10'); LoginGuard::unlock('alice@example.com'); LoginGuard::normalizeIp('::ffff:203.0.113.10'); // '203.0.113.10', the form stored and logged LoginGuard::activeLockouts(); // Collection of Lockout models
For a login flow that does not go through Auth::attempt(), call the guard yourself:
LoginGuard::ensureCanAttempt($username, $request->ip(), $request->userAgent()); // throws when locked or blocked if (! $valid) { LoginGuard::recordFailure($username, $request->ip(), $request->userAgent()); } else { LoginGuard::recordSuccess($username, $request->ip()); }
Events
use Illuminate\Support\Facades\Event; use NovWyatt\LoginGuard\Events\LoginLocked; use NovWyatt\LoginGuard\Events\SuspiciousActivity; Event::listen(function (LoginLocked $event) { // $event->lockout->subject (Subject::Username or Subject::Ip), ->identifier, ->level, ->locked_until // $event->ip, $event->username: the attempt that triggered the lock }); Event::listen(function (SuspiciousActivity $event) { // $event->reason (SuspicionReason::BlocklistedIp or ManyUsernames), $event->ip, $event->username, $event->context });
Alerts
Set at least one channel and the guard alerts on lockouts and suspicious activity:
LOGIN_GUARD_ALERT_MAIL=security@example.com LOGIN_GUARD_ALERT_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/… LOGIN_GUARD_ALERT_TELEGRAM_BOT_TOKEN=123456:ABC… LOGIN_GUARD_ALERT_TELEGRAM_CHAT_ID=-1001234567890
Mail uses your application's mailer. Slack and Telegram are called with Laravel's HTTP client; no extra package is needed.
Audit log
Rows of NovWyatt\LoginGuard\Models\LoginEvent (login_guard_events table) have a type (failed, locked,
blocked, unlocked, suspicious, succeeded), ip, username, user_agent, details and created_at.
Schedule the pruning:
// routes/console.php use Illuminate\Support\Facades\Schedule; use NovWyatt\LoginGuard\Models\LoginEvent; Schedule::command('model:prune', ['--model' => [LoginEvent::class]])->daily();
Testing
composer test # Pest composer analyse # Larastan, level max composer format # Pint
Credits
A Laravel rewrite of web-brute-force-detection, a Flask experiment by the same author.
License
MIT. See LICENSE.md.