Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Fortify Hardening For Laravel Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Make sure laravel/fortify is installed and its migrations/config are in place. This package is a decorator around Fortify — it is not a replacement.
  2. Leave 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.
  3. Check the registration half of the config: fortify-hardening.registration.notification (points at the notice class) and fortify-hardening.registration.notice_mentions_password_reset (defaults on — see below).
  4. If you have previously published the package's translations, re-publish or patch them. The subject now places the application name with an :app placeholder; a stale published translation shows a literal :app or drops the name.
  5. Verify the binding actually resolved, rather than assuming:
    app(\Laravel\Fortify\LoginRateLimiter::class)::class; // should be the package's subclass
    
  6. Exercise it once by hand: submit five bad passwords for a known account, then inspect 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:

  • Your security review flagged that email addresses and client IPs are sitting in Redis and being shipped to your error tracker as cache-call arguments.
  • Your registration endpoint returns a distinguishable response for "email already registered" (especially in a Livewire form), and you want a regression test that actually holds.

Implementation Patterns

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:

  • Fortify's login pipeline (POST /login) — untouched.
  • The 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:

  • Keep one deliberately failing fixture (a fake component that renders "already registered") in your suite so you notice if the assertion ever goes vacuous.
  • Normalize the identity before the request and before the assertion (lowercase/trim) so the <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.

Gotchas and Tips

  • Rotating 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.
  • Counts reset on deploy. Make sure the on-call runbook doesn't assume lockouts survive a release; expect a lockout-alert blip right after deploys during attack traffic.
  • You lose log correlation. 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.
  • Error trackers still record the key — it's just useless. Opaque strings appear in cache call breadcrumbs. Don't mistake that for scrubbing; the tracker is still capturing whatever else you pass it.
  • Config caching bites both flags. 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.
  • Published translations don't update themselves. The v0.3.0 subject uses :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.
  • A greeting is optional, a name is not. If you subclass and applicationName() returns an empty string, the subject reads badly. Always fall back to config('app.name').
  • The Livewire fingerprint normalizes the identity because repeating it is not enumeration. "We sent a link to [email protected]" for both known and unknown is safe. But a page that phrases it differently for a known address still fails — fix the flow, don't loosen the assertion.
  • Identity variants can slip past the substitution. If your form uppercases or trims the address but the server echoes a differently-cased copy, <identity> won't match. Normalize on the way in.
  • Binding order matters. If your app or another package also binds 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.
  • Fixed-length keys are a quiet upside for Memcached. The hash removes the risk of long emails approaching the 250-byte key limit.
  • Don't double-hash. A 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.
  • Extension points: subclass the bound limiter for audit logging, alerting, or tenant scoping while keeping hasExceededRateLimits/increment ordering intact; subclass the registration notification for application naming and greeting. Both are config-selected, so no container juggling is needed.
  • A hardcoded five per minute is Fortify's, not this package's. v0.3.0 changes nothing about limits or decay — keep that in mind when writing your own changelog entry.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib