coding-libs/laravel-mfa
coding-libs/laravel-mfa adds multi-factor authentication to Laravel applications, helping secure user logins with an additional verification step. It integrates with Laravel's authentication flow and offers a straightforward way to implement MFA in your app.
Install and pin correctly. v2.0.0 adds Laravel 13 support. If you're on Laravel 11/12, check composer.json for the illuminate/* constraint before upgrading — a major version bump alongside framework support usually means the support matrix shifted, and older framework versions may have been dropped. Read the diff at the compare link rather than assuming "Laravel 13 support added" is the only change.
composer require coding-libs/laravel-mfa
Publish and migrate. MFA packages need persistence (a secret column on users, or a dedicated table) plus config:
php artisan vendor:publish --provider="CodingLibs\Mfa\MfaServiceProvider"
php artisan migrate
Check the published config first — it's the fastest way to understand the package's model: which drivers exist (TOTP, email OTP, recovery codes), whether secrets are encrypted at rest, the verification window, and the route/middleware names.
Wire up the user model. Add the package's trait to App\Models\User (and to any other authenticatable model). Without it, the package's checks on $user->hasMfaEnabled() / secret accessors won't resolve.
First use case: enroll → challenge → recover. Don't start with enforcement. Start with opt-in enrollment so you can walk the whole loop:
Where to look first, in order: README (flow diagrams, if any) → published config/mfa.php → the package's route file and middleware → the trait's public methods. The trait is the real API surface; the config tells you which defaults are safe.
Middleware on route groups, not globally. The idiomatic shape is: auth middleware establishes identity, then the MFA middleware asserts the session has cleared the challenge. Applying MFA globally breaks login, password reset, and health checks. Apply it to the authenticated app area:
Route::middleware(['auth', 'mfa.verified'])->group(function () {
Route::get('/dashboard', DashboardController::class);
});
Two-phase session state. The clean pattern is a session flag set only after a valid code, checked by middleware. Keep the challenge route itself outside the MFA group but inside auth. On logout, the session is destroyed anyway; on "remember me" re-login, the flag must not persist — verify this explicitly, it's the most common security hole in MFA integrations.
Enrollment as a two-route flow. GET /mfa/setup (generate + display, stash pending secret in session or a pending_secret column) and POST /mfa/setup (verify code, promote to active, generate recovery codes). Storing the pending secret separately from the active one makes "abandoned enrollment" a non-issue.
Gate sensitive actions, not just pages. Page-level middleware is necessary but not sufficient. For destructive operations (change password, disable MFA, view API tokens, export data), re-assert freshness — either require a code again or require the challenge to have happened within N minutes. This is where MFA actually earns its keep.
Disabling MFA is a privileged operation. Require both password and a current code. Otherwise a stolen session can silently turn MFA off. Also decide your recovery path for users who lose both device and recovery codes — usually an admin action or an email-verified reset with a delay.
Programmatic checks in policies and jobs. Where you can't use middleware (queued jobs, console commands, API tokens), branch on the user's MFA state directly and require step-up auth for high-risk scopes. Sanctum/API token auth generally bypasses session MFA — treat token issuance as the security boundary instead, and consider requiring a recent MFA challenge before minting a token.
Testing. Use the package's test helpers or fake the challenge state to log in as an MFA-cleared user (actingAs plus setting the session flag). For code verification, generate a TOTP from the known test secret with a pinned timestamp rather than hardcoding a code that expires mid-run. Assert the negative cases too: valid password + invalid code must not reach the app area; a recovery code must work exactly once.
Views. If the package ships Blade views, publish them and replace the QR rendering with your own if you're using a JS QR library client-side. Server-side QR generation pulls in an image dependency; keeping the secret in the view is acceptable only over HTTPS and only on the enrollment screen.
The v2 upgrade itself. A major bump for a framework-support line often carries dependency constraint changes. Before upgrading: re-publish the config and diff it against yours (vendor:publish --force into a temp dir), check for renamed config keys or middleware aliases, and re-run your full MFA test suite — not just the login smoke test. Watch for the package's service provider registration changing if it moved to Laravel's package discovery conventions.
Secrets at rest. If the package encrypts secrets using APP_KEY, rotating APP_KEY without re-encrypting invalidates every user's secret and locks out your entire userbase. Confirm whether secrets are encrypted and whether a re-key command exists. If they're stored plaintext, wrap the accessor.
Time drift. TOTP verification is window-sensitive. A window of ±1 step (30s each) is the usual default tradeoff; tightening it causes support tickets from users with unsynced phones, widening it weakens the guarantee. Don't change it without a reason.
Rate limiting is on you (probably). Verify endpoints are brute-force targets — a 6-digit code with unlimited attempts is a few hours of work. Confirm the package rate-limits attempts and that failed attempts are counted per-user, not per-IP, then add your own throttle middleware as belt-and-braces. Also lock out after N consecutive failures.
Recovery codes. They should be hashed, single-use, and invalidated when MFA is re-enrolled or disabled. Verify all three; "shown once, stored hashed, consumed on use" is the minimum. If the package gives you ten and doesn't tell you when they run low, add a UI nudge.
Middleware ordering. MFA middleware must run after auth and after StartSession — placing it in the global stack causes it to fire on login itself. In Laravel 11+ the bootstrap/app.php middleware configuration is where this bites people; route-group application avoids the problem entirely.
Debugging a stuck user. The fastest fix is via tinker: clear the MFA columns on the user record and force re-enrollment. Don't add a "master code" or a dev bypass env flag — it will ship to production. If you need one for CI, use a dedicated testing-only service provider that's autoloaded under require-dev.
CLI and queued work bypasses all of this. Artisan commands, scheduled jobs, and impersonation features run without a session. Whatever admin tooling can "log in as" a user must either skip MFA deliberately and log an audit event, or be blocked outright. Decide explicitly and write it down.
Extension points worth knowing. Most MFA packages let you swap the code-delivery channel (email/SMS) and override the challenge view via config. If you need SMS, check whether the package expects you to implement a notification contract or just a callback — that determines how much of your provider's SDK leaks into the app.
Watch the version constraint in CI. If you support multiple Laravel versions, run your matrix against the package's supported range; v2.0.0 likely narrowed it, and a green build on your current framework won't tell you when the next framework bump makes the pair incompatible.
How can I help you explore Laravel packages today?