olusegun171/laravel-mfa

Multi-factor authentication for Laravel — TOTP compatible with Google Authenticator, Authy, and any RFC 6238 app.

Maintainers

Package info

github.com/olusegun171/laravel-mfa

pkg:composer/olusegun171/laravel-mfa

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main / 1.0.x-dev 2026-05-12 16:23 UTC

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.

Tests Latest Version on Packagist Total Downloads PHP Laravel License: MIT

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)
  • TwoFactor facade + HasTwoFactor Eloquent 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_secret or two_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