Security & Authentication

Implementing Two-Factor Authentication (2FA) in Laravel 12

Learn how to implement Two-Factor Authentication (2FA) in Laravel 12 using PragmaRX Google2FA package. Complete guide with QR codes, backup codes, and security best practices.

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

|
03-Nov-2025
10 min read
Implementing Two-Factor Authentication (2FA) in Laravel 12

Introduction

In today's digital landscape, password-only authentication is no longer sufficient. Two-Factor Authentication (2FA) adds a critical security layer by requiring users to verify their identity through a second device—typically a smartphone app like Google Authenticator or Authy.

Whether you're building a SaaS application, financial platform, or any system handling sensitive data, implementing 2FA is essential for protecting user accounts from unauthorized access, phishing attacks, and credential theft.

In this comprehensive guide, we'll implement 2FA in Laravel 12 using PragmaRX Google2FA Laravel—a robust package that integrates Google's Time-based One-Time Password (TOTP) algorithm seamlessly with Laravel's authentication system.

By the end of this tutorial, you'll have a production-ready 2FA system with QR code generation, backup codes, and a smooth user experience.


Why Two-Factor Authentication Matters

The Security Gap

Passwords alone are vulnerable to:

  • Brute force attacks – Automated password guessing
  • Phishing – Social engineering to steal credentials
  • Data breaches – Leaked password databases
  • Keyloggers – Malware capturing keystrokes

How 2FA Protects You

2FA requires two authentication factors:

  1. Something you know – Your password
  2. Something you have – Your smartphone with authenticator app

Even if attackers steal passwords, they cannot access accounts without the second factor, reducing account takeover risk by 99.9% according to Microsoft's research.


Why Choose PragmaRX Google2FA?

The PragmaRX Google2FA Laravel package offers:

  • Laravel Integration – Built specifically for Laravel applications
  • QR Code Generation – Easy setup with visual QR codes
  • TOTP Standard – Compatible with Google Authenticator, Authy, Microsoft Authenticator
  • Window Tolerance – Handles time synchronization issues
  • Backup Codes – Recovery options when devices are unavailable
  • Well Maintained – Regular updates and Laravel 12 compatibility
  • Lightweight – Minimal dependencies and overhead

Installation and Setup

Step 1: Install the Package

Install PragmaRX Google2FA Laravel via Composer:

composer require pragmarx/google2fa-laravel

The package will auto-register its service provider in Laravel 12.

Step 2: Install QR Code Generator

For generating QR codes, install BaconQrCode:

composer require bacon/bacon-qr-code

Step 3: Publish Configuration

Publish the configuration file (optional):

php artisan vendor:publish --provider="PragmaRX\Google2FALaravel\ServiceProvider"

This creates config/google2fa.php where you can customize settings.

Step 4: Add Database Columns

Create a migration to add 2FA fields to the users table:

php artisan make:migration add_two_factor_columns_to_users_table
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->text('google2fa_secret')->nullable();
            $table->boolean('google2fa_enabled')->default(false);
            $table->timestamp('google2fa_enabled_at')->nullable();
            $table->json('recovery_codes')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn([
                'google2fa_secret',
                'google2fa_enabled',
                'google2fa_enabled_at',
                'recovery_codes'
            ]);
        });
    }
};

Run the migration:

php artisan migrate

Step 5: Update User Model

Add fillable fields and casts to your App\Models\User model:

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    protected $fillable = [
        'name',
        'email',
        'password',
        'google2fa_secret',
        'google2fa_enabled',
        'google2fa_enabled_at',
        'recovery_codes',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
        'google2fa_enabled' => 'boolean',
        'google2fa_enabled_at' => 'datetime',
        'recovery_codes' => 'array',
    ];

    protected $hidden = [
        'password',
        'remember_token',
        'google2fa_secret',
        'recovery_codes',
    ];
}

Implementing 2FA Setup Flow

Create Controller for 2FA Management

Generate a controller to handle 2FA operations:

php artisan make:controller TwoFactorController
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;

class TwoFactorController extends Controller
{
    protected $google2fa;

    public function __construct()
    {
        $this->middleware('auth');
        $this->google2fa = new Google2FA();
    }

    /**
     * Show 2FA setup page
     */
    public function index()
    {
        $user = auth()->user();
        
        return view('auth.two-factor.index', [
            'enabled' => $user->google2fa_enabled,
            'enabledAt' => $user->google2fa_enabled_at,
        ]);
    }

    /**
     * Generate secret and QR code
     */
    public function setup()
    {
        $user = auth()->user();
        
        // Generate secret key
        $secret = $this->google2fa->generateSecretKey();
        
        // Store temporarily (not enabled yet)
        $user->update(['google2fa_secret' => $secret]);
        
        // Generate QR Code
        $qrCodeUrl = $this->google2fa->getQRCodeUrl(
            config('app.name'),
            $user->email,
            $secret
        );
        
        $writer = new Writer(
            new ImageRenderer(
                new RendererStyle(200),
                new SvgImageBackEnd()
            )
        );
        
        $qrCodeSvg = $writer->writeString($qrCodeUrl);
        
        return view('auth.two-factor.setup', [
            'secret' => $secret,
            'qrCode' => $qrCodeSvg,
        ]);
    }

    /**
     * Enable 2FA after verification
     */
    public function enable(Request $request)
    {
        $request->validate([
            'one_time_password' => 'required|numeric',
            'password' => 'required',
        ]);

        $user = auth()->user();

        // Verify password
        if (!Hash::check($request->password, $user->password)) {
            return back()->withErrors(['password' => 'Invalid password']);
        }

        // Verify OTP
        $valid = $this->google2fa->verifyKey(
            $user->google2fa_secret,
            $request->one_time_password
        );

        if (!$valid) {
            return back()->withErrors([
                'one_time_password' => 'Invalid verification code'
            ]);
        }

        // Generate recovery codes
        $recoveryCodes = $this->generateRecoveryCodes();

        // Enable 2FA
        $user->update([
            'google2fa_enabled' => true,
            'google2fa_enabled_at' => now(),
            'recovery_codes' => $recoveryCodes,
        ]);

        return redirect()
            ->route('two-factor.recovery-codes')
            ->with('success', '2FA enabled successfully');
    }

    /**
     * Show recovery codes
     */
    public function showRecoveryCodes()
    {
        $user = auth()->user();

        if (!$user->google2fa_enabled) {
            return redirect()->route('two-factor.index');
        }

        return view('auth.two-factor.recovery-codes', [
            'recoveryCodes' => $user->recovery_codes,
        ]);
    }

    /**
     * Regenerate recovery codes
     */
    public function regenerateRecoveryCodes(Request $request)
    {
        $request->validate(['password' => 'required']);

        $user = auth()->user();

        if (!Hash::check($request->password, $user->password)) {
            return back()->withErrors(['password' => 'Invalid password']);
        }

        $recoveryCodes = $this->generateRecoveryCodes();
        $user->update(['recovery_codes' => $recoveryCodes]);

        return back()->with('success', 'Recovery codes regenerated');
    }

    /**
     * Disable 2FA
     */
    public function disable(Request $request)
    {
        $request->validate([
            'password' => 'required',
            'one_time_password' => 'required|numeric',
        ]);

        $user = auth()->user();

        if (!Hash::check($request->password, $user->password)) {
            return back()->withErrors(['password' => 'Invalid password']);
        }

        // Verify OTP
        $valid = $this->google2fa->verifyKey(
            $user->google2fa_secret,
            $request->one_time_password
        );

        if (!$valid) {
            return back()->withErrors([
                'one_time_password' => 'Invalid verification code'
            ]);
        }

        // Disable 2FA
        $user->update([
            'google2fa_secret' => null,
            'google2fa_enabled' => false,
            'google2fa_enabled_at' => null,
            'recovery_codes' => null,
        ]);

        return redirect()
            ->route('two-factor.index')
            ->with('success', '2FA disabled successfully');
    }

    /**
     * Generate recovery codes
     */
    protected function generateRecoveryCodes(): array
    {
        $codes = [];
        
        for ($i = 0; $i < 8; $i++) {
            $codes[] = Str::random(10);
        }
        
        return $codes;
    }
}

Creating Authentication Middleware

Create middleware to enforce 2FA verification during login:

php artisan make:middleware TwoFactorAuthentication
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class TwoFactorAuthentication
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = auth()->user();

        // Skip if 2FA not enabled
        if (!$user || !$user->google2fa_enabled) {
            return $next($request);
        }

        // Skip if already verified in this session
        if (session('2fa_verified')) {
            return $next($request);
        }

        // Redirect to 2FA verification page
        return redirect()->route('two-factor.verify');
    }
}

Register the middleware in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        '2fa' => \App\Http\Middleware\TwoFactorAuthentication::class,
    ]);
})

2FA Verification During Login

Create a controller for login verification:

php artisan make:controller TwoFactorVerificationController
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use PragmaRX\Google2FA\Google2FA;

class TwoFactorVerificationController extends Controller
{
    protected $google2fa;

    public function __construct()
    {
        $this->middleware('auth');
        $this->google2fa = new Google2FA();
    }

    public function show()
    {
        return view('auth.two-factor.verify');
    }

    public function verify(Request $request)
    {
        $request->validate([
            'one_time_password' => 'required|numeric',
        ]);

        $user = auth()->user();

        // Verify OTP
        $valid = $this->google2fa->verifyKey(
            $user->google2fa_secret,
            $request->one_time_password,
            8 // Window tolerance for time sync issues
        );

        if ($valid) {
            // Mark session as 2FA verified
            session(['2fa_verified' => true]);
            
            return redirect()
                ->intended(route('dashboard'))
                ->with('success', 'Successfully authenticated');
        }

        return back()
            ->withErrors(['one_time_password' => 'Invalid verification code'])
            ->withInput();
    }

    public function useRecoveryCode(Request $request)
    {
        $request->validate([
            'recovery_code' => 'required|string',
        ]);

        $user = auth()->user();
        $recoveryCodes = $user->recovery_codes ?? [];
        $code = $request->recovery_code;

        // Check if recovery code exists
        if (!in_array($code, $recoveryCodes)) {
            return back()->withErrors([
                'recovery_code' => 'Invalid recovery code'
            ]);
        }

        // Remove used recovery code
        $remainingCodes = array_filter($recoveryCodes, fn($c) => $c !== $code);
        $user->update(['recovery_codes' => array_values($remainingCodes)]);

        // Mark session as verified
        session(['2fa_verified' => true]);

        return redirect()
            ->route('dashboard')
            ->with('warning', 'Recovery code used. Please regenerate new codes.');
    }
}

Defining Routes

Add routes in routes/web.php:

use App\Http\Controllers\TwoFactorController;
use App\Http\Controllers\TwoFactorVerificationController;

Route::middleware(['auth'])->group(function () {
    // 2FA Management
    Route::get('/two-factor', [TwoFactorController::class, 'index'])
        ->name('two-factor.index');
    
    Route::get('/two-factor/setup', [TwoFactorController::class, 'setup'])
        ->name('two-factor.setup');
    
    Route::post('/two-factor/enable', [TwoFactorController::class, 'enable'])
        ->name('two-factor.enable');
    
    Route::post('/two-factor/disable', [TwoFactorController::class, 'disable'])
        ->name('two-factor.disable');
    
    Route::get('/two-factor/recovery-codes', [TwoFactorController::class, 'showRecoveryCodes'])
        ->name('two-factor.recovery-codes');
    
    Route::post('/two-factor/recovery-codes/regenerate', [TwoFactorController::class, 'regenerateRecoveryCodes'])
        ->name('two-factor.recovery-codes.regenerate');
    
    // 2FA Verification
    Route::get('/two-factor/verify', [TwoFactorVerificationController::class, 'show'])
        ->name('two-factor.verify')
        ->withoutMiddleware(['2fa']);
    
    Route::post('/two-factor/verify', [TwoFactorVerificationController::class, 'verify'])
        ->name('two-factor.verify.post')
        ->withoutMiddleware(['2fa']);
    
    Route::post('/two-factor/recovery', [TwoFactorVerificationController::class, 'useRecoveryCode'])
        ->name('two-factor.recovery')
        ->withoutMiddleware(['2fa']);
});

// Protected routes requiring 2FA
Route::middleware(['auth', '2fa'])->group(function () {
    Route::get('/dashboard', function () {
        return view('dashboard');
    })->name('dashboard');
});

Creating Blade Views

Setup Page (resources/views/auth/two-factor/setup.blade.php)

<x-app-layout>
    <div class="py-12">
        <div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6">
                    <h2 class="text-2xl font-bold mb-6">Set Up Two-Factor Authentication</h2>
                    
                    <div class="mb-6">
                        <p class="text-gray-600 mb-4">
                            Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
                        </p>
                        
                        <div class="flex justify-center mb-4">
                            {!! $qrCode !!}
                        </div>
                        
                        <div class="bg-gray-100 p-4 rounded">
                            <p class="text-sm text-gray-600 mb-2">Or enter this code manually:</p>
                            <code class="text-lg font-mono">{{ $secret }}</code>
                        </div>
                    </div>
                    
                    <form method="POST" action="{{ route('two-factor.enable') }}">
                        @csrf
                        
                        <div class="mb-4">
                            <label class="block text-sm font-medium mb-2">
                                Verification Code
                            </label>
                            <input 
                                type="text" 
                                name="one_time_password"
                                class="w-full border rounded px-3 py-2"
                                placeholder="Enter 6-digit code"
                                required
                                autofocus
                            >
                            @error('one_time_password')
                                <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                            @enderror
                        </div>
                        
                        <div class="mb-6">
                            <label class="block text-sm font-medium mb-2">
                                Confirm Password
                            </label>
                            <input 
                                type="password" 
                                name="password"
                                class="w-full border rounded px-3 py-2"
                                required
                            >
                            @error('password')
                                <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                            @enderror
                        </div>
                        
                        <button type="submit" class="bg-blue-500 text-white px-6 py-2 rounded">
                            Enable 2FA
                        </button>
                    </form>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

Verification Page (resources/views/auth/two-factor/verify.blade.php)

<x-guest-layout>
    <div class="max-w-md mx-auto">
        <h2 class="text-2xl font-bold mb-6">Two-Factor Authentication</h2>
        
        <form method="POST" action="{{ route('two-factor.verify.post') }}">
            @csrf
            
            <div class="mb-4">
                <label class="block text-sm font-medium mb-2">
                    Enter 6-digit code from your authenticator app
                </label>
                <input 
                    type="text" 
                    name="one_time_password"
                    class="w-full border rounded px-3 py-2 text-center text-2xl"
                    maxlength="6"
                    required
                    autofocus
                >
                @error('one_time_password')
                    <p class="text-red-500 text-sm mt-1">{{ $message }}</p>
                @enderror
            </div>
            
            <button type="submit" class="w-full bg-blue-500 text-white py-2 rounded mb-4">
                Verify
            </button>
        </form>
        
        <div class="text-center">
            <button 
                onclick="document.getElementById('recovery-form').classList.toggle('hidden')"
                class="text-sm text-blue-500"
            >
                Use recovery code instead
            </button>
        </div>
        
        <form 
            id="recovery-form" 
            method="POST" 
            action="{{ route('two-factor.recovery') }}"
            class="hidden mt-4"
        >
            @csrf
            <input 
                type="text" 
                name="recovery_code"
                class="w-full border rounded px-3 py-2 mb-2"
                placeholder="Enter recovery code"
            >
            <button type="submit" class="w-full bg-gray-500 text-white py-2 rounded">
                Use Recovery Code
            </button>
        </form>
    </div>
</x-guest-layout>

Best Practices and Security Considerations

1. Time Window Tolerance

The Google2FA library supports window tolerance to handle time synchronization issues:

$valid = $this->google2fa->verifyKey($secret, $code, 8);

This checks 8 time windows (4 before, current, 3 after) for better user experience.

2. Rate Limiting

Add rate limiting to verification endpoints:

Route::post('/two-factor/verify', [TwoFactorVerificationController::class, 'verify'])
    ->middleware('throttle:5,1'); // 5 attempts per minute

3. Session Security

Clear 2FA verification status on logout:

public function logout(Request $request)
{
    Auth::logout();
    $request->session()->forget('2fa_verified');
    $request->session()->invalidate();
    $request->session()->regenerateToken();
    
    return redirect('/');
}

4. Encrypted Storage

Consider encrypting secrets in the database:

protected $casts = [
    'google2fa_secret' => 'encrypted',
    'recovery_codes' => 'encrypted:array',
];

Conclusion

Implementing Two-Factor Authentication in Laravel 12 using PragmaRX Google2FA Laravel dramatically improves your application's security posture. With QR code setup, backup recovery codes, and smooth user flows, you provide enterprise-grade protection without sacrificing user experience.

Key takeaways:

  • Install and configure Google2FA in minutes
  • Generate QR codes for easy authenticator app setup
  • Create recovery codes for account recovery
  • Protect routes with 2FA middleware
  • Handle time synchronization gracefully

Need help implementing 2FA or other security features in your Laravel application? NeedLaravelSite specializes in building secure, production-ready Laravel applications. Contact us for expert Laravel development services.


Related Resources:


Article Tags

laravel security 2fa authentication google-authenticator

About the Author

Muhammad Waqas

Muhammad Waqas

CEO at CentoSquare

Founder & CEO at CentoSquare | Creator of NeedLaravelSite | Helping Businesses Grow with Cutting-Edge Web, Mobile & Marketing Solutions | Building Innovative Products