olusegun171 / laravel-mfa
Multi-factor authentication for Laravel — TOTP compatible with Google Authenticator, Authy, and any RFC 6238 app.
Requires
- php: ^8.1
- ext-openssl: *
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.0|^11.0|^12.0|^13.0
This package is auto-updated.
Last update: 2026-05-12 16:23:56 UTC
README
Multi-factor authentication for Laravel. Works with Google Authenticator, Authy, 1Password, Bitwarden, and any other RFC 6238 compatible app.
Features
- TOTP codes — RFC 6238 compliant, 6-digit, 30-second window
- QR code URI generation for any authenticator app
- AES-256-CBC encrypted secret storage
- 8 bcrypt-hashed one-time recovery codes
- Clock-drift tolerance (±1 time-step)
TwoFactorfacade +HasTwoFactorEloquent trait
Requirements
- PHP 8.1+
- Laravel 10, 11, 12, or 13
Installation
composer require olusegun171/laravel-mfa
The service provider and TwoFactor facade are registered automatically via package auto-discovery.
Setup
1. Publish the config
php artisan vendor:publish --tag=two-factor-config
2. Run the migration
# Resolves the table from the guard's Eloquent model automatically php artisan two-factor:install --guard=web # Or pass the table directly php artisan two-factor:install --table=admins php artisan migrate
This adds three nullable columns to your users table:
| Column | Description |
|---|---|
two_factor_secret |
AES-256-CBC encrypted TOTP secret |
two_factor_recovery_codes |
JSON array of bcrypt-hashed one-time backup codes |
two_factor_confirmed_at |
Timestamp set when the user confirms their first code |
3. Add the trait to your model
use Olusegun171\TwoFactor\Traits\HasTwoFactor; class User extends Authenticatable { use HasTwoFactor; }
Usage
See the Integration section for full usage examples split by authenticated and unauthenticated context.
Status Helpers
TwoFactor::remainingRecoveryCodes($user); // number of unused backup codes // Model methods via HasTwoFactor trait $user->hasTwoFactorEnabled(); // true once two_factor_confirmed_at is set $user->hasTwoFactorPending(); // true if setup started but not yet confirmed
QR Code Identifier
By default the QR code label uses getAuthIdentifier() — typically the user's primary key. To show something friendlier (like an email address) in the authenticator app, add getTwoFactorIdentifier() to your model:
class User extends Authenticatable { use HasTwoFactor; public function getTwoFactorIdentifier(): string { return $this->email; } }
The label will appear as YourApp:user@example.com inside the authenticator app.
Configuration
// config/two-factor.php return [ 'issuer' => env('MFA_ISSUER', null), // shown in authenticator apps; defaults to app name 'totp' => [ 'digits' => 6, 'period' => 30, // seconds per time-step 'window' => 1, // ±1 period tolerance for clock drift 'algorithm' => 'sha1', ], ];
Security Notes
- Rate-limit the challenge endpoint — 5 attempts per minute is a reasonable starting point.
- Serve over HTTPS — codes in transit must be encrypted.
- Recovery codes are shown once — only bcrypt hashes are stored in the database.
- All comparisons use
hash_equals()for constant-time evaluation. - TOTP secrets are encrypted with AES-256-CBC using a 32-byte slice of your
APP_KEY. - Never log
two_factor_secretortwo_factor_recovery_codes.
Integration
Authenticated context (settings or an enforced page)
The user is already logged in. They enable 2FA from their account settings or a dedicated page to enforce the 2fa, scan the QR code, and confirm with their first code.
Enable and show the QR code
$data = TwoFactor::generate($user); // Pass $data to your view: // $data['qr_code_url'] — <img src="{{ $data['qr_code_url'] }}"> // $data['secret'] — manual entry fallback // $data['recovery_codes'] — show once, store somewhere safe
Confirm the first code
try { TwoFactor::confirm($user, $request->code); } catch (InvalidCodeException $e) { return back()->withErrors(['code' => $e->getMessage()]); }
Disable 2FA
TwoFactor::disable($user);
Regenerate recovery codes
$codes = TwoFactor::regenerateRecoveryCodes($user); // string[]
Unauthenticated context (login flow)
The user is not yet logged in. Auth::login() is not called until the 2FA code is verified — the user is fully unauthenticated between the password step and the code step.
Step 1 — Password check (LoginController)
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Olusegun171\TwoFactor\Facades\TwoFactor; $user = User::where('email', $request->email)->first(); if (!$user || !Hash::check($request->password, $user->password)) { return back()->withErrors(['email' => 'Invalid credentials.']); } if (TwoFactor::requiresChallenge($user)) { return redirect()->route('two-factor.challenge'); } // 2FA not set up — enforced: require setup before granting access $setup = TwoFactor::setup($user); return redirect()->route('two-factor.setup')->with('setup', $setup);
Step 2 — Challenge routes
Wrap the challenge routes with the two-factor middleware so they redirect to login if accessed directly (no pending session).
Route::middleware('two-factor')->group(function () { Route::get('/two-factor/challenge', [TwoFactorChallengeController::class, 'show'])->name('two-factor.challenge'); Route::post('/two-factor/challenge', [TwoFactorChallengeController::class, 'store']); Route::post('/two-factor/recovery', [TwoFactorChallengeController::class, 'recover']); });
Step 3 — Challenge controller
use Olusegun171\TwoFactor\Exceptions\InvalidCodeException; use Olusegun171\TwoFactor\Facades\TwoFactor; // Submit a TOTP code public function store(Request $request) { $user = TwoFactor::getPendingUser(); try { TwoFactor::verify($user, $request->code); } catch (InvalidCodeException $e) { return back()->withErrors(['code' => $e->getMessage()]); } TwoFactor::completeChallenge(); Auth::login($user); $request->session()->regenerate(); return redirect()->intended('/dashboard'); } // Submit a recovery code instead public function recover(Request $request) { $user = TwoFactor::getPendingUser(); try { TwoFactor::verifyRecoveryCode($user, $request->recovery_code); } catch (InvalidCodeException $e) { return back()->withErrors(['recovery_code' => $e->getMessage()]); } TwoFactor::completeChallenge(); Auth::login($user); $request->session()->regenerate(); return redirect()->intended('/dashboard'); }
completeChallenge() clears the pending session state. The caller is responsible for Auth::login() and session()->regenerate().
License
MIT — see LICENSE