Secure Your Laravel Apps with Two-Factor Authentication and Laravel Fortify

July 15, 2025
Written by
Lucky Opuama
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Secure Your Laravel Apps with Two-Factor Authentication and Laravel Fortify

Security has always been a major challenge in web applications, especially with user authentication. With the increasing number of cyber threats, relying solely on passwords is no longer enough. Attackers can easily compromise weak or reused passwords through phishing, brute force attacks, or data breaches. To enhance security, Two-Factor Authentication (2FA) has become a widely adopted solution.

Laravel Fortify, Laravel’s authentication backend, provides built-in support for two-factor authentication using TOTP (Time-based One-Time Passwords). However, many users find SMS-based authentication more convenient than using authentication apps. That’s where Twilio comes in. Twilio’s SMS API allows us to send OTPs directly to users’ mobile devices, making the authentication process more seamless and user-friendly.

In this tutorial, you will learn how to secure your Laravel apps by implementing SMS-based 2FA using Twilio and Laravel Fortify.

What is Two-factor authentication?

Two-factor authentication (2FA), sometimes called Two-step Verification or Dual-factor Authentication, is a security process in which users provide two different authentication factors to verify themselves.

2FA adds an extra layer of protection to Laravel applications by requiring users to verify their identity through a second authentication factor, typically an OTP (One-Time Password) sent via SMS, email, or an authentication app. With using it, even if an attacker gains access to a user’s password, they still cannot log in without the second verification step.

What is Laravel Fortify?

Laravel Fortify is a frontend agnostic authentication backend implementation for Laravel. This means that Laravel Fortify provides all the necessary backend logic for user registration, login, password resets, email verification, and other authentication features without dictating how the user interface should look. With this flexibility, you can design and implement a fully customized frontend while smoothly leveraging Fortify’s powerful authentication features in the background.

Prerequisites

Scaffold a basic Laravel project

Given that Composer is installed globally on your computer, start a new project by running the following command, wherever you store your PHP projects:

laravel new twilio-2fa

To proceed with the installation process, choose "No starter kit"and "Pest" as your desired testing framework when prompted.

Screenshot of Laravel CLI asking to install a starter kit and choose a testing framework

Still at the installation stage. In the next step, select "SQLite" as the database for your application, then confirm "Yes" to run the default database migrations.

Command-line interface displaying options to choose a database and run default migrations.

After completing the installation process, your project should be successfully created. Navigate into its directory by running the following command:

cd twilio-2fa

Next, start the development server by running the following command:

php artisan serve

Now, you can access your newly created application in your browser by visiting: http://localhost:8000. You should see the default Laravel welcome page confirming your application is running successfully.

Laravel getting started page with documentation link, video tutorials, and 'Deploy now' button.

Install and configure Laravel Fortify

To install Laravel Fortify, run the following command in a new terminal window or tab:

composer require laravel/fortify

To publish Fortify's authentication actions to the app/Actions directory, generate the FortifyServiceProvider, publish the Fortify configuration file, and include all necessary database migrations required for authentication, run the following command:

php artisan fortify:install

Next, open the project directory in your IDE or editor of choice. Then, navigate to app/Actions/Fortify directory, open the CreateNewUser.php file, and update with the following code.

<?php

namespace App\Actions\Fortify;

use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;

class CreateNewUser implements CreatesNewUsers
{
    use PasswordValidationRules;

    public function create(array $input): User
    {
        Validator::make($input, [
            'name' => ['required', 'string', 'max:255'],
            'email' => [
                'required',
                'string',
                'email',
                'max:255',
                Rule::unique(User::class),
            ],
            'password' => $this->passwordRules(),
            'phone' => ['required', 'string', 'unique:users'],
        ])->validate();

        return User::create([
            'name' => $input['name'],
            'email' => $input['email'],
            'password' => Hash::make($input['password']),
            'phone' => $input['phone'],
        ]);
    }
}

This code manages the user registration using Laravel Fortify. The key update made was including a phone number field because you will be receiving the authentication code via SMS.

Update the users table

The "users" table is an important database table in this tutorial. It stores and manages user-related information. It acts as the foundation for user data management, authorization, and authentication.

Now, let's update the user migration file. Navigate to database/migrations directory, open the last file whose name ends with _add_two_factor_columns_to_users_table.php, and update the table with the code below:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('phone')->after('email')->nullable();
            $table->string('two_factor_code')->after('password')->nullable();
            $table->timestamp('two_factor_expires_at')->after('two_factor_code')->nullable();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn([
                'phone',
                'two_factor_code',
                'two_factor_expires_at',
            ]);
        });
    }
};

Laravel Fortify automatically implements the "name", "email", and "password" fields. In addition, the following fields were added to the table:

  • phone: This stores the user's phone number, which is required for sending SMS-based OTPs
  • two_factor_code: This stores the OTP (One-Time Password) generated for 2FA
  • two_factor_expires_at: This stores the expiration time for the OTP, ensuring that the OTP is valid only for a limited period

Next, run the following command to migrate your database:

php artisan migrate

Update the User model

The User model serves as the primary representation of the users table. It interacts with the "users" table in the database and provides authentication, enabling data retrieval and notification handling. To update the User model, navigate to the app/Models directory, open the User.php file, and update it with the code below:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'phone',
        'two_factor_code',
        'two_factor_expires_at'
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'password' => 'hashed',
            'two_factor_expires_at' => 'datetime',
        ];
    }
}

The $fillable array defines which attributes can be mass assigned when creating or updating a user, and the $hidden array defines which attributes should not be included in JSON responses.

Install Twilio's Official PHP Helper Library

To simplify interacting with Twilio's Programmable Messaging API, you will need Twilio's PHP Helper Library installed. To install the library, run the command below.

composer require twilio/sdk

Retrieve your Twilio credentials

Before proceeding, it's important to securely store your credentials to prevent exposing sensitive information in your codebase. Instead of hardcoding credentials, you can dynamically retrieve them from environment variables stored in the .env file.

To properly use these credentials, navigate to the config directory, open the services.php file, and add the following configuration code:

'twilio' => [
    'sid' => env('TWILIO_SID'),
    'auth_token' => env('TWILIO_AUTH_TOKEN'),
    'phone_number' => env('TWILIO_PHONE_NUMBER'),
],

Now, copy and paste the following at the end of .env, located in the project's top-level directory.

TWILIO_SID=<<your_twilio_sid>>
TWILIO_AUTH_TOKEN=<<your_twilio_auth_token>>
TWILIO_PHONE_NUMBER=<<your_twilio_phone_number>>

Next, you need to retrieve these three key credentials from your Twilio account:

  • Account SID: A unique identifier for your Twilio account
  • Auth Token: A secret key used for authentication
  • Twilio Phone Number: A virtual number assigned to your account for sending messages and making calls
Twilio account info including Account SID, Auth Token, and phone number.

Now, log in to your Twilio Dashboard and copy these credentials located in the Account Info section, into .env, replacing the respective placeholders.

Create a Twilio service

Now, let's create the 2FA service to handle SMS operations. Navigate to your app directory and create a new folder named Services. Inside the Services folder, create a new file named TwilioService.php, and add the following code to the file:

<?php

namespace App\Services;

use Twilio\Rest\Client;

class TwilioService
{
    protected $client;

    public function __construct()
    {
        $this->client = new Client(config('services.twilio.sid'), config('services.twilio.auth_token'));
    }

    public function sendSms($to, $message)
    {
        return $this->client
            ->messages
            ->create(
                $to, 
                [
                    'from' => config('services.twilio.phone_number'),
                    'body' => $message
                ]
            );
    }
}

This service class initializes Twilio's REST API client using the credentials stored in config/services.php, and provides a reusable method to send SMS messages.

Create the controller

Now, let's generate a controller named TwoFactorController. To do this, run the following command:

php artisan make:controller TwoFactorController

Next, navigate to your app/Http/Controllers directory, open TwoFactorController.php file, and replace its content with the following code:

<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\TwilioService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;

class TwoFactorController extends Controller
{
    protected $twilio;

    public function __construct(TwilioService $twilio)
    {
        $this->twilio = $twilio;
    }

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

    public function sendCode()
    {
        /** @var User $user */
        $user = Auth::user();
        if (!$user) {
            return redirect()->route('login')->withErrors(['error' => 'User not found.']);
        }

        if (!$user->phone) {
            return redirect()->route('2fa.verify')->withErrors(['phone' => 'Your phone number is not set.']);
        }

        $code = rand(100000, 999999);
        $user->update([
            'two_factor_code' => $code,
            'two_factor_expires_at' => Carbon::now()->addMinutes(10),
        ]);

        try {
            $this->twilio->sendSms($user->phone, "Your 2FA code is: $code");
        } catch (\Exception $e) {
            return redirect()->route('2fa.verify')->withErrors(['error' => 'Failed to send verification code.']);
        }

        return redirect()
            ->route('2fa.verify')
            ->with('message', 'A verification code has been sent to your phone.');
    }

    public function verifyCode(Request $request)
    {
        $request->validate(['two_factor_code' => 'required|numeric']);

        /** @var User $user */
        $user = Auth::user();
        if (
            $user->two_factor_code === $request->two_factor_code
            && $user->two_factor_expires_at->isFuture()
        ) {
            $user->update(['two_factor_code' => null, 'two_factor_expires_at' => null]);
            return redirect()->route('login');
        }

        return redirect()
            ->route('2fa.verify')
            ->withErrors(['two_factor_code' => 'Invalid or expired code.']);
    }

    public function authenticated(Request $request, User $user)
    {
        if ($user->two_factor_code) {
            return redirect()->route('2fa.verify');
        }

        return redirect()->route('login');
    }
}

This controller handles the generation, storage, and verification of the 2FA code. It also ensures that users cannot log in without verifying their identity through the Twilio SMS-based authentication code.

Let’s go step-by-step and analyze how these methods are implemented:

  • The verifyPage() method prevents unauthorized access
  • The sendCode() method generates a random 6-digit code, saves it to the database, and sends it to the user via SMS
  • The verifyCode() method validates the entered code and grants access if it is correct
  • The authenticated() method ensures that users verify their 2FA code before moving to the next page

Create the frontend view

Now, let's implement the application's frontend view by creating the template files for the authentication controller’s methods. To create the frontend template files, navigate to the resources/views directory and create a new folder named auth. Inside the auth folder, create the following files:

  • register.blade.php
  • login.blade.php
  • two-factor.blade.php

Now, inside the register.blade.php file, add the code below.

@extends('layouts.app')
@section('content')
    <div class="row justify-content-center">
        <div class="col-md-6">
            <div class="card">
                <div class="card-header text-center bg-primary text-white">
                    <h4>Register</h4>
                </div>
                <div class="card-body">
                    @if ($errors->any())
                        <div class="alert alert-danger">
                            <ul>
                                @foreach ($errors->all() as $error)
                                    <li>{{ $error }}</li>
                                @endforeach
                            </ul>
                        </div>
                    @endif
                    <form method="POST" action="{{ route('register') }}">
                        @csrf
                        <div class="mb-3">
                            <label for="name" class="form-label">Full Name</label>
                            <input type="text" name="name" class="form-control" required placeholder="Enter your full name">
                        </div>
                        <div class="mb-3">
                            <label for="email" class="form-label">Email Address</label>
                            <input type="email" name="email" class="form-control" required placeholder="Enter your email">
                        </div>
                        <div class="mb-3">
                            <label for="phone" class="form-label">Phone Number</label>
                            <input type="text" name="phone" class="form-control" required placeholder="Enter your phone number">
                        </div>
                        <div class="mb-3">
                            <label for="password" class="form-label">Password</label>
                            <input type="password" name="password" class="form-control" required placeholder="Enter password">
                        </div>
                        <div class="mb-3">
                            <label for="password_confirmation" class="form-label">Confirm Password</label>
                            <input type="password" name="password_confirmation" class="form-control" required placeholder="Confirm password">
                        </div>
                        <button type="submit" class="btn btn-primary w-100">Register</button>
                    </form>
                    <div class="text-center mt-3">
                        <small>Already have an account? <a href="{{ route('login') }}">Login here</a></small>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

Inside the login.blade.php file, add the code below.

@extends('layouts.app')
@section('content')
    <div class="row justify-content-center">
        <div class="col-md-6">
            <div class="card">
                <div class="card-header text-center bg-primary text-white">
                    <h4>Login</h4>
                </div>
                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success">
                            {{ session('status') }}
                        </div>
                    @endif
                    @if ($errors->any())
                        <div class="alert alert-danger">
                            <ul>
                                @foreach ($errors->all() as $error)
                                    <li>{{ $error }}</li>
                                @endforeach
                            </ul>
                        </div>
                    @endif
                    <form method="POST" action="{{ route('login') }}">
                        @csrf
                        <div class="mb-3">
                            <label for="email" class="form-label">Email Address</label>
                            <input type="email" name="email" class="form-control" required placeholder="Enter your email">
                        </div>
                        <div class="mb-3">
                            <label for="password" class="form-label">Password</label>
                            <input type="password" name="password" class="form-control" required placeholder="Enter your password">
                        </div>
                        <button type="submit" class="btn btn-primary w-100">Login</button>
                    </form>
                    <div class="text-center mt-3">
                        <small>Don't have an account? <a href="{{ route('register') }}">Register here</a></small>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

Inside the two-factor.blade.php file, add the code below.

@extends('layouts.app')
@section('content')
    <div class="row justify-content-center">
        <div class="col-md-6">
            <div class="card">
                <div class="card-header text-center bg-warning text-white">
                    <h4>Two-Factor Authentication</h4>
                </div>
                <div class="card-body">
                    @if(session('message'))
                        <div class="alert alert-success">
                            {{ session('message') }}
                        </div>
                    @endif
                    @if ($errors->any())
                        <div class="alert alert-danger">
                            <ul>
                                @foreach ($errors->all() as $error)
                                    <li>{{ $error }}</li>
                                @endforeach
                            </ul>
                        </div>
                    @endif
                    <p class="text-center">Enter the 6-digit code sent to your phone.</p>
                    <form method="POST" action="{{ route('2fa.check') }}">
                        @csrf
                        <div class="mb-3">
                            <input type="text" name="two_factor_code" class="form-control text-center" required placeholder="Enter code">
                        </div>
                        <button type="submit" class="btn btn-warning w-100">Verify</button>
                    </form>
                    <div class="text-center mt-3">
                        <a href="{{ route('2fa.send') }}" class="btn btn-link">Send Code</a>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

Now, navigate to the resources/views directory and create a new folder named layouts. Inside this folder, create a file called app.blade.php. This file will serve as the main layout for your application, providing a consistent structure for all your pages. Add the following code to this file.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ $title ?? 'Twilio 2FA' }}</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        body {
            background-color: #f8f9fa;
        }
        .container {
            margin-top: 50px;
        }
        .card {
            border-radius: 10px;
            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        }
    </style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
    <div class="container">
        <a class="navbar-brand" href="{{ route('home') }}">Twilio 2FA</a>
    </div>
</nav>
<div class="container">
    @yield('content')
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Lastly, create the home page ( home.blade.php) inside the resource/views directory. Add the following code:

@extends('layouts.app')
@section('content')
    <div class="text-center mt-5">
        <h1>Welcome, {{ auth()->user()->name }}!</h1>
        <p>You are now logged in.</p>
        <form action="{{ route('logout') }}" method="POST">
            @csrf
            <button type="submit">Logout</button>
        </form>
    </div>
@endsection

Add the routes

Let's define how the application will handle incoming requests by integrating the web routes. Navigate to the routes directory, open the web.php file, and update it to match the following code:

<?php

use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Auth;

Route::get('/', function () {
    return view('welcome');
});

Route::middleware(['auth'])->group(function () {
    Route::get('/2fa', [App\Http\Controllers\TwoFactorController::class, 'verifyPage'])->name('2fa.verify');
    Route::post('/2fa', [App\Http\Controllers\TwoFactorController::class, 'verifyCode'])->name('2fa.check');
    Route::get('/2fa/send', [App\Http\Controllers\TwoFactorController::class, 'sendCode'])->name('2fa.send');
    Route::get('/home', function () {
        return view('home');
    })->name('home');
});

Route::post('/logout', function () {
    Auth::logout();
    session()->invalidate();
    session()->regenerateToken();
    return redirect('/login');
})->name('logout');

Now, let's configure the redirection path for authenticated users. By default, Laravel Fortify automatically handles this, but you need to customize it to ensure users are directed to the two-factor authentication page.

To do this, navigate to the config directory, open the fortify.php file, and update the home path from /home to /2fa.

'home' => '/2fa',

Customize the Fortify Service Provider

TheFortify Service Provideris a backend authentication implementation for Laravel applications. This Service Provider customizes authentication behavior, defines views, and enforces the 2FA.

To customize the Fortify Service Provider, navigate to the app/Providers directory, open the FortifyServiceProvider.php file, and replace its contents with the following code:

<?php

namespace App\Providers;

use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;

class FortifyServiceProvider extends ServiceProvider
{
    public function register(): void {}

    public function boot(): void
    {
        Fortify::createUsersUsing(CreateNewUser::class);
        Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
        Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
        RateLimiter::for('login', function (Request $request) {
            $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip());
            return Limit::perMinute(5)->by($throttleKey);
        });
        RateLimiter::for('two-factor', function (Request $request) {
            return Limit::perMinute(5)->by($request->session()->get('login.id'));
        });
        Fortify::loginView(fn() => view('auth.login'));
        Fortify::registerView(fn() => view('auth.register'));
        Fortify::requestPasswordResetLinkView(fn() => view('auth.forgot-password'));
        Fortify::resetPasswordView(fn($request) => view('auth.reset-password', ['request' => $request]));
        Fortify::verifyEmailView(fn() => view('auth.verify-email'));
        Fortify::twoFactorChallengeView(fn() => view('auth.two-factor'));
        Fortify::authenticateUsing(function (Request $request) {
            $user = User::where('email', $request->email)->first();
            if ($user && Auth::attempt($request->only('email', 'password'))) {
                if ($user->two_factor_code) {
                    return redirect()->route('2fa.verify');
                }
                return $user;
            }
            return null;
        });
    }
}

The customizations made to this code are as follows:

  • Authentication using phone instead of email: The authentication system has been modified to use a phone number and password instead of the default email and password combination
  • Custom authentication views: Instead of using Fortify's default views, custom authentication views have been set, allowing for a fully tailored user interface
  • 2FA enforcement: If a user has 2FA enabled, they are redirected to a verification page before completing the login process
  • Rate limiting for login and 2FA: To enhance security, rate limits have been implemented for both login attempts and two-factor authentication, preventing excessive or automated login attempts

Test the application

To test the application, open http://127.0.0.1:8000/register in your browser and complete the registration form as shown in the screenshot below.

Image of a registration form with fields for full name, email address, phone number, password, and confirm password.

Once registered, you will be redirected to the 2FA page. As shown in the screenshot, click the Send Code button to receive an SMS from Twilio containing your 2FA code.

Two-factor authentication screen with a field for entering a code and a verify button.

Enter the received code, click the Verify button, and upon successful verification, you will be redirected to the home page.

If you're already a registered user, visit http://127.0.0.1:8000/login and log in using your email and password, as shown in the screenshot below.

Login screen with fields for email and password and a registration link at the bottom.

That's how to Implement 2FA with Twilio and Laravel Fortify

Integrating Twilio for SMS-based authentication enables users to receive a unique verification code that must be entered to complete the registration and login process. Laravel Fortify simplifies this implementation by handling the authentication logic, while Twilio ensures reliable message delivery — as always.

With this setup, you can secure your Laravel apps from unauthorized access, improving security and trust. For even greater flexibility and security, you can extend this implementation by incorporating other authentication methods, such as email-based verification or app-based authenticators.

Happy coding!

Lucky Opuama is a software engineer and technical writer with a passion for exploring new tech stacks and writing about them. Connect with him on LinkedIn.

Password icon created by kliwir art on Flaticon.