Search by

novwyatt / laravel-login-guard

NovWyatt

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.

Package info

github.com/NovWyatt/laravel-login-guard

pkg:composer/novwyatt/laravel-login-guard

Statistics

Installs: 15

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-10-14 08:30 UTC

This package is auto-updated.

Last update: 2026-10-14 08:31:19 UTC


README

English · Tiếng Việt

tests License: MIT

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.
  • LoginLocked and SuspiciousActivity events. 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:list and login-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.