pushery / fortify-hardening-for-laravel
Stops Laravel Fortify from revealing whether an account exists: neutral answers on registration and password reset, equal timing, and a notice to the owner.
Package info
github.com/pushery/fortify-hardening-for-laravel
pkg:composer/pushery/fortify-hardening-for-laravel
Requires
- php: ^8.4
- ext-json: *
- laravel/fortify: ^1.0
- laravel/framework: ^13.0
- symfony/http-foundation: ^7.0 || ^8.0
Requires (Dev)
- fakerphp/faker: ^1.24
- larastan/larastan: ^3.9.3
- laravel/ai: ^1.0
- laravel/boost: ^2.9
- laravel/mcp: ^1.0
- laravel/pao: ^1.1
- laravel/pint: ^1.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-agent: ^5.0
- pestphp/pest-plugin-browser: ^5.0
- pestphp/pest-plugin-evals: ^5.1
- pestphp/pest-plugin-laravel: ^5.0
- pestphp/pest-plugin-phpstan: ^5.0
- pestphp/pest-plugin-type-coverage: ^5.0
- phpstan/phpstan: ^2.3.0
- rector/rector: ^2.7.0
- spaze/phpstan-disallowed-calls: ^4.12
- symfony/yaml: ^7.4.17 || ^8.1.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Fortify Hardening for Laravel
Stops Laravel Fortify from revealing whether an account exists: neutral answers on registration and password reset, equal timing, and a notice to the owner.
Documentation
Full documentation at docs.pushery.com/fortify-hardening-for-laravel. This README is the quick start; the guides, the configuration reference and the upgrade notes live there.
Requirements
- PHP 8.4+
- Laravel 13+
- Laravel Fortify 1.34.1+, with its
registrationandresetPasswordsfeatures
Tested on PostgreSQL and MySQL 8.4, the databases Laravel Cloud runs (serverless Postgres and MySQL 8.4 LTS), so it runs there out of the box. The fast suite runs on SQLite.
Tested on PHP 8.4 — the floor of the declared range, because a >= promise is
only proven by running on the boundary — and on PHP 8.5, so the upper half of
^8.4 is exercised here rather than first by a consumer.
Installation
composer require pushery/fortify-hardening-for-laravel
The service provider is registered automatically through package discovery.
What it changes
Fortify tells anyone who asks whether an email address has an account: its registration form refuses a taken address, its reset-link form refuses an unknown one, and a password reset names the difference too. With the package installed, Fortify's own routes answer every case alike.
| Where | Fortify on its own | With the package |
|---|---|---|
| Registration | a taken address is refused, a free one is signed in | both read "check your inbox", nobody is signed in, the holder of a taken address gets a notice |
| Reset link | an unknown address is refused, a recent one is told to wait | every request reads like a link that was sent |
| Password reset | an unknown address and a wrong token give different errors | every failure reads like a wrong token |
| Timing | a taken address answers sooner, a known one takes longer on the password routes | both answers take the same time |
Nothing needs to be wired: the package puts its answers in place of Fortify's through the
container. Give new accounts a verification mail (MustVerifyEmail on the user model) and show
session('status') on the sign-in page, and the registration reads the same for everybody. The
installation guide has the
details, and every setting is listed in the
configuration reference.
Usage
On Fortify's route there is nothing to call. An application that registers users in a controller of its own uses the registrar, which looks the address up, creates the account or sends the notice, and signs nobody in:
use App\Models\User; use Pushery\FortifyHardening\Registration\Registrar; public function store(RegisterRequest $request, Registrar $registrar): RedirectResponse { $registrar->register( $request->string('email')->value(), $request->string('password')->value(), fn (string $hashedPassword): User => User::create([ 'name' => $request->string('name')->value(), 'email' => $request->string('email')->value(), 'password' => $hashedPassword, ]), ); return redirect()->route('login') ->with('status', __('fortify-hardening::messages.registration.accepted')); }
The registration guide covers the form request, the notice to the holder and its mail budget.
To prove the property in your own suite, use the assertion the package ships:
use Illuminate\Testing\TestResponse; use Pushery\FortifyHardening\Testing\AssertsEnumerationSafety; uses(AssertsEnumerationSafety::class); it('answers an unknown address like a known one', function () { User::factory()->create(['email' => 'known@example.com']); $this->assertEnumerationSafe( fn (string $email): TestResponse => $this->post('/forgot-password', ['email' => $email]), 'known@example.com', 'unknown@example.com', ); });
See Testing your application for registration and password reset examples.
Testing
composer test
The PostgreSQL and MySQL suites need a reachable server and skip without one, so
a green run on a bare checkout has not exercised them, and the summary counts each of
their cases as skipped. Point them at a server with the PG_TEST_* / MYSQL_TEST_* variables
(defaults: 127.0.0.1, ports 5432 and 3308, users postgres / root), and set
REQUIRE_DB_TESTS=1 to turn an unreachable server into a failure instead of a skip.
REQUIRE_DB_TESTS=1 composer test
Security
Please review the security policy and report vulnerabilities privately rather than opening a public issue.
Built by Pushery
This package is built and maintained by Pushery — a Berlin-based studio building Laravel applications, SaaS products, and open-source tools.
Building a Laravel UI? WireKit is Pushery's open-source Livewire component kit. Browse the rest of our work at pushery.com.
License
The MIT License (MIT). See LICENSE for details.