pushery/fortify-hardening-for-laravel
Security hardening for Laravel Fortify. Adds stricter password rules, login throttling and rate limiting, safer session defaults, and optional two-factor enforcement, so your app's authentication is secure out of the box with minimal configuration and no loss of Fortify features.
This package hardens Fortify in two mostly independent areas: it rebinds Fortify's LoginRateLimiter so lockout keys are opaque hashes, and it adds enumeration-safe registration (a "someone tried to register with your address" notice, plus an assertEnumerationSafe() test helper). v0.3.0 only touches the registration/notification half — the limiter behaviour is unchanged.
composer require <vendor>/<package>
php artisan vendor:publish --tag=fortify-hardening-config
Steps for a first use case:
laravel/fortify is installed and its migrations/config are in place. This package is a decorator around Fortify — it is not a replacement.FORTIFY_HARDENING_HASHED_LOGIN_KEYS unset (defaults to on) unless you are debugging locally and need to eyeball live keys in Redis/cache table/your APM.fortify-hardening.registration.notification (points at the notice class) and fortify-hardening.registration.notice_mentions_password_reset (defaults on — see below).:app placeholder; a stale published translation shows a literal :app or drops the name.app(\Laravel\Fortify\LoginRateLimiter::class)::class; // should be the package's subclass
Cache::store(config('fortify.limiter')) and confirm the recorded key looks like an opaque hash rather than [email protected]|203.0.113.7.Where to look first in the source: the service provider's singleton/bind for LoginRateLimiter and the key()/limiterKey() override; then the registration pipeline's response fingerprinting behind assertEnumerationSafe().
First realistic use cases:
Keep Fortify as the source of truth for thresholds. The package only changes the key derivation. If you need a different limit or window, change config/fortify.php — do not fork the limiter class to change 5.
Treat the limiter as opaque infrastructure. Day-to-day you interact with it through three surfaces:
POST /login) — untouched.Lockout event — listen here for alerting, and log the address/IP yourself in the listener. That is now the only place the identity is available at lockout time.RateLimiter::clear($key) — any code that clears failed logins (post password reset, support intervention) must hash the key with the same derivation, or it silently clears nothing.Wrap the key derivation once. Expose a helper taking (email, ip) that mirrors the package's derivation, or better, resolve the bound limiter from the container and call the same method. Never re-derive by hand in five places.
Customize the registration notice by subclassing, not by editing. Point fortify-hardening.registration.notification at your class and override:
applicationName($notifiable) — required when one installation serves several brands/tenants; return the name of the instance the attempt landed on.greeting($notifiable) — return the holder's name, or null/empty to omit the greeting entirely.Both receive the notifiable (the address holder, not the submitter), so tenant-scoped naming and personalization come for free. Keep the constructor, via(), and mail wiring of the parent intact — the notification is the only signal the legitimate account holder gets.
Turn off the password-reset line for passwordless apps. If your app is magic-link/SSO only, set fortify-hardening.registration.notice_mentions_password_reset to false. The last line then points at the sign-in page alone, in all seven bundled languages. Leave it true if you have any password-based path — mixed-mode apps are the common case and a stale "reset your password" hint is worse than a slightly redundant one.
Testing registration enumeration. Use assertEnumerationSafe() on the real registration endpoint, driving both a known and an unknown identity through the same form in the same request shape. As of v0.3.0 the fingerprint understands Livewire update responses: it strips snapshot checksums, normalizes randomly drawn component IDs to <component-id>, and replaces the submitted identity with <identity> in the status line, errors, redirect, and body. A response field that actually distinguishes known from unknown still fails the assertion — that is the point. Two disciplines that keep the test meaningful:
<identity> substitution and the server's echo use byte-identical strings.Testing rate limits. They break in a specific way: they assert on the presence of a raw key like [email protected]|127.0.0.1. Prefer behavioural assertions (five assertStatus(302) + assertSessionHasErrors, sixth 429 / assertGuest()), or reset via your hashed-key helper, or use a fresh array cache store per test.
Multi-guard / multi-tenant apps. hash(email|ip) keeps the same single flat namespace, so the same email in two tenants still shares one bucket. If that matters, handle it with your own limiter subclass. For the registration notice, tenant context is a naming concern only — it does not scope the limiter.
Environment split. Reasonable to set FORTIFY_HARDENING_HASHED_LOGIN_KEYS=false in local only, so redis-cli keys '*' stays legible. Keep it on in staging and production — staging is where your error tracker usually matches prod.
Queue the notice. The registration-side win is only real if the response is indistinguishable, and mail sending is not free. Make your notification ShouldQueue (or at least Notification::fake() it in enumeration tests) so a known address doesn't produce a visibly slower response than an unknown one. Timing is the enumeration channel the assertion cannot see.
APP_KEY silently resets all login lockouts, and unlocks in-flight locked-out users. Usually a desirable safety valve, but a deploy that rotates the key hands an attacker a clean slate. Document the interaction.redis-cli --scan --pattern '*1.2.3.4*' and grepping your tracker for the offending IP no longer work. Log email + IP in a Lockout listener before the counter becomes the only record. This is the single most important operational adjustment.FORTIFY_HARDENING_HASHED_LOGIN_KEYS and notice_mentions_password_reset set in .env after config:cache appear to do nothing. Re-run config:cache and re-verify.:app; an older published language file will render the placeholder literally or omit the app name. Re-publish (check php artisan vendor:publish for the translations tag) or add :app to your overrides. Same trap if you translated the password-reset line yourself — the new false variant needs its own translated string.applicationName()/greeting() receive the notifiable, not the request. In multi-tenant setups, don't reach for tenant() from a global helper inside them — resolve the tenant from the notifiable or from an explicitly passed property, otherwise a queued notification runs with the wrong context.applicationName() returns an empty string, the subject reads badly. Always fall back to config('app.name').<identity> won't match. Normalize on the way in.LoginRateLimiter (custom lockout messaging, per-tenant limiters), the later registration wins. Extend the package's class rather than replacing it, so you keep the hashing.RateLimiter::clear() helper that hashes an already-hashed key appears to work (clear() clears something) and produces a green test while production stays locked out.hasExceededRateLimits/increment ordering intact; subclass the registration notification for application naming and greeting. Both are config-selected, so no container juggling is needed.How can I help you explore Laravel packages today?