Laravel 12 Two-Factor Authentication (2FA) with OTP Login Verification

Laravel 12 provides a good security layer for web applications. Simple password-based authentication is no longer enough to protect user accounts from unauthorized access. This is where Two-Factor Authentication (2FA) using OTP (One-Time Password) comes into play. This is the most secured way to login and verify a user into system.

To complete and giving idea we will flow below steps for implementing 2FA  in laravel 12 project

  1. Install Fresh Laravel 12 Project
  2. Install Ui/Auth Bootstrap
  3. Install PHP Flasher
  4. Define Necessary Routes
  5. Define Necessary Controllers
  6. Add Migration and Model for Otp
  7. Define Necessary Services
  8. Add Mailable OtpSending Class
  9. Add Blade File
  10. Run Project


In this tutorial, we will guide you by implementing Laravel 12 Login Verification using 2FA OTP, where users will receive a 6-digit OTP via email for added security. This ensures that even if a password is leaked, unauthorized access can still be prevented.

Why Use OTP-Based 2FA in Laravel?

  1. Enhanced Security – Prevents unauthorized logins even if passwords are leaked.
  2. User-Friendly – OTPs are easy to use and require no extra setup for users.
  3. Email-Based Verification – No need for third-party SMS services and free of charges.
  4. Flexible & Scalable – Can be expanded to support SMS OTP, Google Authenticator, or other 2FA methods.


By the end of this tutorial, you’ll have a fully functional OTP-based 2FA system in Laravel 12 that sends OTP codes via email and verifies them before granting access. So, Let’s get started! 

Install Fresh Laravel 12 Project

We are going to implement 2FA verification in our new laravel 12 project. You can also use in your existing project. In that case modify your controllers, model, routes according to need. For your kind information, this article is also valid for laravel 11 project

composer create-project --prefer-dist laravel/laravel laravel12-2fa
cd laravel12-2fa

Install Ui/Auth Bootstrap

We will use laravel ui/auth login authentication library. You can also use laravel breeze as well. Let's install laravel ui/auth package and run necessary commands

composer require laravel/ui 
php artisan ui bootstrap --auth
npm install && npm run build

Install PHP Flasher

For showing success and error message after redirection of pages we need to install another popular php flasher library package

composer require php-flasher/flasher-laravel
php artisan flasher:install

Define Necessary Routes

We need to have another two routes in our routes/web.php file. Modify your route file based on this

use App\Http\Controllers\HomeController;
use App\Http\Controllers\OtpController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return redirect('/home');
});

Route::middleware(['auth'])->group(function () {
    Route::get('/home', [HomeController::class, 'index'])->name('home');

    Route::get('/verify-otp', [OtpController::class, 'verifyOtpForm'])->name('send.otp');
    Route::post('/verify-otp-action', [OtpController::class, 'verifyOtp'])->name('verify.otp');

    Route::middleware(['otp.verified'])->group(function () {
        Route::get('/dashboard', [HomeController::class, 'dashboard'])->name('dashboard');
    });
});

Auth::routes();

Define Necessary Controllers

We need to define one controller(OtpController) and modify existing three controllers. Here those controllers are listed below. You can see and modify these controllers according to need. 

LoginController.php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;

class LoginController extends Controller
{
	//app/Http/Controllers\Auth\LoginController
	
    use AuthenticatesUsers;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected string $redirectTo = '/dashboard';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest')->except('logout');
        $this->middleware('auth')->only('logout');
    }

    protected function authenticated()
    {
        return redirect($this->redirectTo);
    }
}

RegisterController.php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\OtpService;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;

class RegisterController extends Controller
{
	//app/Http/Controllers\Auth\RegisterController
    
    use RegistersUsers;

    /**
     * Where to redirect users after registration.
     *
     * @var string
     */
    protected $redirectTo = '/home';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest');
    }

    public function register(Request $request)
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:6',
        ]);

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);


        if ($user) {
            (new OtpService())->sendOtp($user);
        }

        flash()->addSuccess("You have successfully registered. You can login now");

        // Redirect to email verification notice
        return redirect()->route('login');
    }

    protected function validator(array $data)
    {
        return Validator::make($data, [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ]);
    }
}

OtpController.php

namespace App\Http\Controllers;

use App\Models\Otp;
use App\Services\OtpService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class OtpController extends Controller
{
	//app/Http/Controllers\Auth\OtpController
	
    private OtpService $otpService;

    public function __construct()
    {
        $this->otpService = new OtpService();
    }

    public function verifyOtpForm(Request $request)
    {
        $user = Auth::user();
        if (!$user) {
            abort(401, 'User not authenticated');
        }

        // Fetch latest OTP for the user
        $otpRecord = Otp::where('user_id', $user->id)->latest()->first();
        if (!$otpRecord) {
            return redirect('/dashboard');
        }
        return view('verify-otp');
    }


    public function verifyOtp(Request $request)
    {
        $request->validate([
            'otp' => 'required|numeric',
        ]);
        $user = Auth::user();

        if (!$user) {
            abort(401, 'User not authenticated');
        }

        // Fetch latest OTP for the user
        $otpRecord = Otp::where('user_id', $user->id)->latest()->first();

        if (!$otpRecord || $otpRecord->isExpired() || $otpRecord->otp != $request->otp) {
            flash()->addError('Invalid or expired token');
            return redirect('/verify-otp');
        }

        // OTP is valid, delete it
        $otpRecord->delete();
        flash()->addSuccess('OTP verified successfully.');
        return redirect('/dashboard');
    }
}

Add Migration and Model for Otp

For saving and managing otp we need have an otp model. Let's create Otp model along with migration. This command will create Otp model class and also a migration file

php artisan make:model Otp -m

Otp Model

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Otp extends Model
{
	//app\Models\Otp
    use HasFactory;

    protected $fillable = ['user_id', 'otp', 'expires_at'];

    public function isExpired()
    {
        return Carbon::now()->greaterThan($this->expires_at);
    }
}

Otp Migration File

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::create('otps', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('otp');
            $table->timestamp('expires_at');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('otps');
    }
};

Define Necessary Services

We will create a OtpService class for clean code architechture. Here is OtpService class file

namespace App\Services;

use App\Mail\OtpMail;
use App\Models\Otp;
use Carbon\Carbon;
use Illuminate\Support\Facades\Mail;

class OtpService
{
	//app/Services/OtpService
	
    public function sendOtp($user)
    {
        if (!$user) {
            abort(401, 'Unauthenticated');
        }

        // Generate a 6-digit OTP
        $otpCode = rand(100000, 999999);

        // Delete old OTPs for this user
        Otp::where('user_id', $user->id)->delete();

        // Save new OTP
        Otp::create([
            'user_id' => $user->id,
            'otp' => $otpCode,
            'expires_at' => Carbon::now()->addMinutes(10), // OTP valid for 10 minutes
        ]);

        // Send OTP via email
        Mail::to($user->email)->send(new OtpMail($otpCode));

        return ['message' => 'OTP sent to your email.'];
    }
}

Add Mailable OtpMail Class

As we are using email for sending otp to user. So, we need define a mailable class that will be used for handle mail.

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class OtpMail extends Mailable
{
	//app/Mail/OtpMail
    use Queueable, SerializesModels;

    public mixed $otp;
    
    public function __construct($otp)
    {
        $this->otp = $otp;
    }

    
    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Otp Mail',
        );
    }
    
    public function content(): Content
    {
        return new Content(
            view: 'emails.otp',
        );
    }
    
    public function attachments(): array
    {
        return [];
    }
}

Add Blade File

As we are using laravel/ui auth package, some blade files will be created by default. Here we need extra verify-otp.blade.php file

@extends('layouts.app')

@section('content')
{{--    resources/views/verify-otp.blade.php--}}
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card">
                    <div class="card-header">{{ __('Otp Page') }}</div>

                    <div class="card-body">
                        @if (session('status'))
                            <div class="alert alert-success" role="alert">
                                {{ session('status') }}
                            </div>
                        @endif

                        <form action="{{ route('verify.otp') }}" method="POST">
                            @csrf
                            <label for="otp">Enter OTP:</label>
                            <input type="text" class="form-control" name="otp" placeholder="Enter 6 digits otp" minlength="6" maxlength="6" required>
                            <button type="submit" class="btn btn-sm btn-primary mt-2">Verify</button>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection
Define OtpVerifiedMiddleware

We are protecting dashboard and other routes under otp.verified middleware. That's why we will have to define a middleware class. This will prevent accessing otp.verified protected routes. Here is the class

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

use App\Models\Otp;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class OtpVerifiedMiddleware
{
	//app/Http/Middleware/OtpVerifiedMiddleware
    public function handle(Request $request, Closure $next)
    {
        if (Auth::check() && Otp::where('user_id', Auth::id())->exists()) {
            return redirect()->route('send.otp');
        }

        return $next($request);
    }
}

it's needed to register our defined middleware in bootstrap/app.php file under ->withMiddleware method. If you are using laravel lower versions , then you will have to register inside kernel.php file

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'otp.verified' => \App\Http\Middleware\OtpVerifiedMiddleware::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();

Run Project

Here we have successfully completed our simple project. You can just run project and access routes. You need to register a new user and tried to login with given credentials. After login you will be redirected to otp-verification page. After filling and validating otp, you are finally good to go to dashboard as well as other routes. If you feel you are experiencing issues, then you can visit this github link as reference 


Tags: