yeonik / login-defense
Progressive login protection for PHP: track failed attempts, escalate to a captcha challenge, then to a timed lockout. Framework-agnostic core, thin Laravel bridge.
Requires
- php: ^8.3
- psr/clock: ^1.0
- psr/event-dispatcher: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/simple-cache: ^3.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- nyholm/psr7: ^1.8
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- illuminate/support: To use the Laravel bridge (service provider + middleware). Laravel 12 and 13 are supported.
- psr/http-client-implementation: Required by the reCAPTCHA/hCaptcha verifiers (any PSR-18 client).
- psr/http-factory-implementation: Required by the reCAPTCHA/hCaptcha verifiers (any PSR-17 factory).
README
Progressive login protection for PHP: track failed attempts, escalate to a captcha challenge, then to a timed lockout — with the escalation policy explicit, configurable and tested. Framework-agnostic core, thin Laravel bridge included.
below the threshold → Allow
a few failures later → RequireCaptcha
too many failures → Lockout (retry-after grows, then caps)
Why this exists
"Why not just use the framework's rate limiter?"
The built-ins are binary — under the limit you pass, over it you're blocked. Real
attacks live in between: slow credential stuffing that stays under any sane limit,
distributed attempts that never trip a per-IP counter. Production needs
graduated friction — cheap for legitimate users, increasingly expensive for
automation — plus an explicit, auditable policy for when each step engages. This
package makes that escalation a first-class, configurable, tested object instead
of scattered if statements in a controller.
"Why is the core framework-agnostic?" Because abuse mitigation is domain logic, not HTTP plumbing. The rules for when to demand a captcha do not depend on whether the request arrived through Laravel, Symfony, or a queue worker. Keeping the decision in pure PHP behind PSR interfaces means it is testable without booting a framework — and the core test suite proves it by running with no framework installed at all.
Install
composer require yeonik/login-defense
Requires PHP 8.3+. The core depends only on PSR interfaces
(simple-cache, event-dispatcher, clock, http-client, http-factory,
http-server-middleware) — installing this package pulls in no framework.
The optional Laravel bridge supports Laravel 12 and 13, both covered by CI,
and is auto-discovered when illuminate/support is present.
Publish the config (Laravel):
php artisan vendor:publish --tag=login-defense-config
The escalation ladder
The decision is a pure function of the failure count on a key and the config:
| Failures on the key | Decision |
|---|---|
< captcha_after (default 3) |
Allow |
< lockout_after (default 6) |
RequireCaptcha |
>= lockout_after |
Lockout, retryAfter = min(base · mult^extra, max) |
retryAfter starts at base_seconds (60s) and doubles with each further failure
until it hits max_seconds (1h), where it stays. All thresholds and windows are
configurable in config/login-defense.php.
Usage
Framework-agnostic core
The application owns its authentication; the guard owns the escalation decision.
use LoginDefense\Core\LoginGuard; // 1. Before checking credentials, ask the guard what to do. $decision = $guard->check($email, $ip, $request->captchaToken()); if ($decision->isLockedOut()) { return response('Too many attempts', 429) ->withHeader('Retry-After', (string) $decision->retryAfter); } if ($decision->requiresCaptcha()) { return response('Captcha required', 422); } // 2. Now run your own credential check, and report the outcome back. if ($this->credentialsAreValid($email, $password)) { $guard->recordSuccess($email, $ip); // clears the counter for this key } else { $guard->recordFailure($email, $ip); // moves the key up the ladder }
A ready-made PSR-15 middleware is included for stacks that prefer to gate the route directly.
Laravel
Attach the middleware to your login route:
Route::post('/login', LoginController::class) ->middleware(\LoginDefense\Bridge\Laravel\ProtectsLogin::class);
It reads the identifier (email or username), the client IP and
captcha_token from the request, and short-circuits with 429 (locked out) or
422 (captcha required). Reporting the auth outcome stays with your controller
via LoginGuard::recordFailure() / recordSuccess() — the middleware enforces
consequences, it does not guess whether your credentials were correct. Set
login-defense.enabled to false and the middleware becomes a pass-through, with
Laravel's default behaviour unchanged.
Security decisions, and why
Each of these is a deliberate call, commented at the point it is made in the code.
-
Combined key for lockout. Keying on IP alone lets one NAT'd office lock out everyone behind it and does nothing against distributed attempts; keying on the username alone hands an attacker a denial-of-service against the victim's own account. The lockout key is
identifier + IP(hashed), narrowing a lock to "this account, from this source". A separate, looser global per-IP throttle raises friction to a captcha when one IP is noisy across many usernames — but it never locks, precisely so it cannot take out a shared office. -
No user enumeration. Escalation is driven only by the attempt count on the key, never by whether a user record was found. The same code path and the same response shape run whether or not the account exists, so neither the body nor the timing leaks its existence.
-
Captcha outage: fail-closed by default. If the captcha provider times out, the challenge stands (
fail_open = false) — a provider we cannot reach must not wave traffic through. It is configurable. Either way, a provider outage never escalates a user into a lockout: you are not punished for the provider's downtime. -
No secrets in logs. Events (
CaptchaRequired,AccountLockedOut) carry the hashed key and the decision — never the submitted password or the captcha token. The provider secret travels in the POST body, never a URL, and never appears in an exception message. -
Reset semantics. A successful authentication clears the counter for that key. The global per-IP throttle is intentionally left to decay on its own TTL, so one user's success does not hand a co-tenant attacker a clean slate.
Tradeoff: counter atomicity
The bundled PsrCacheAttemptStore is backed by PSR-16, which has no atomic
increment. Its increment() is a read-modify-write, so under concurrent
requests two processes can both read a sub-threshold count, both add one, and both
be let through — the effective threshold can be exceeded by roughly the number of
in-flight requests.
This is not hidden. AttemptStore is an interface precisely so a backend with
a native atomic counter (e.g. Redis INCR) can implement the same contract
race-free, with no change to the decision logic above it. For production under
sustained or adversarial load, back the tracker with such a store. The bundled
PSR-16 store is correct for development, low-traffic apps, and the test suite.
The architecture is the point
Two layers, one seam, visible in the directory structure:
src/Core/ pure PHP, PSR interfaces only — the decision logic
src/Bridge/Laravel/ thin adapter: service provider + middleware
Core/ imports nothing from Bridge/ and nothing from Illuminate\*. The proof
is a CI job that installs no framework at all and runs the core suite against
it:
| Job | PHP | Framework |
|---|---|---|
core |
8.3, 8.4 | none installed |
bridge |
8.3, 8.4 | Laravel 12 and 13 (Orchestra Testbench) |
The bridge job runs four cells — PHP 8.3/8.4 × Laravel 12/13 — with each Laravel
major pinned to its matching Testbench major (12 → Testbench 10, 13 → Testbench 11).
Every push runs composer audit, pint --test, phpstan analyse (level 6,
larastan on the bridge, no baseline, no ignores) and both suites.
composer install
vendor/bin/pint --test
vendor/bin/phpstan analyse
vendor/bin/phpunit --testsuite=core # passes with no framework
vendor/bin/phpunit --testsuite=bridge
composer audit
Scope
Out of scope, on purpose:
- MFA / TOTP. It answers a different question ("is this the right person?") than abuse mitigation ("is this traffic automated?"), and Laravel Fortify already covers it. A separate concern belongs in a separate package.
- A Symfony bridge — planned, not in this release. The core is already framework-free, so a Symfony bundle is a thin addition later; shipping it separately keeps this release small.
- No UI, no routes, no captcha widget rendering, no IP reputation feeds.
License
MIT — see LICENSE.