These days, security is very important. That's why many applications implement two-factor authentication. In this tutorial, I will show you how to do that in Laravel, using Laravel Notifications and sending a one-time password via email or SMS.
Notice: there are more complicated 2FA methods like Google Authenticator, but in this tutorial I prefer the most simple and most widely used approach of email/SMS.
Prepare Laravel Application Back-End
For a quick authentication scaffold, we will use Laravel Breeze. Install it by running these two commands:
composer require laravel/breeze --devphp artisan breeze:install
Next, we need to store our verification code somewhere. Also, we need to set its expiration time, so there's another DB field for this. So, add two fields to the default users
migration:
database/migrations/2014_10_12_000000_create_users_table.php:
public function up(){ Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->rememberToken(); $table->string('two_factor_code')->nullable(); $table->dateTime('two_factor_expires_at')->nullable(); $table->timestamps(); });}
We also add those fields to app/Models/User.php
properties $fillable
array:
class User extends Authenticatable{ protected $fillable = [ 'name', 'email', 'password', 'two_factor_code', 'two_factor_expires_at', ]; // ...
Finally, for filling those fields let's create a method in the User
model:
app/Models/User.php:
public function generateTwoFactorCode(): void{ $this->timestamps = false; $this->two_factor_code = rand(100000, 999999); $this->two_factor_expires_at = now()->addMinutes(10); $this->save();}
Notice: in addition to creating a random code and setting its expiration time, we also specify that this update should not touch the updated_at
column in users
table – so we’re doing $this->timestamps = false;
.
Now, we're ready to use $user->generateTwoFactorCode()
when we need it.
Sending Code via Email
After the successful authentication, we need to generate the code and send it to the user. For that, let's generate a Laravel Notification class:
php artisan make:notification SendTwoFactorCode
The main benefit of using Notifications is that we can provide the channel(s) how to send that notification: email, SMS and others.
Next, we need to edit the Notification's toMail()
method.
app/Notifications/SendTwoFactorCode.php:
class SendTwoFactorCode extends Notification{ public function toMail(User $notifiable): MailMessage { return (new MailMessage) ->line("Your two factor code is {$notifiable->two_factor_code}") ->action('Verify Here', route('verify.index')) ->line('The code will expire in 10 minutes') ->line('If you have not tried to login, ignore this message.'); }
We will create the route('verify.index')
route, which will also re-send the code, a bit later.
As you can see, we use the $notifiable
variable, which automatically represents the recipient of the notification - in our case, the logged-in user themselves.
Now, how/where do we call that notification?
We're using Laravel Breeze, so after login, in the app/Http/Controllers/Auth/AuthenticatedSessionController.php
, we need to add this code in the store()
method:
public function store(LoginRequest $request){ $request->authenticate(); $request->session()->regenerate(); $request->user()->generateTwoFactorCode(); $request->user()->notify(new SendTwoFactorCode()); return redirect()->intended(RouteServiceProvider::HOME);}
This will send an email which will look like this:
Redirecting to Verification Form
Let's build the restriction middleware. Until the logged-in user enters the verification code, they need to be redirected to this form:
To perform that restriction, we will generate a Middleware:
php artisan make:middleware TwoFactorMiddleware
app/Http/Middleware/TwoFactorMiddleware.php:
class TwoFactorMiddleware{ public function handle(Request $request, Closure $next) { $user = auth()->user(); if (auth()->check() && $user->two_factor_code) { if ($user->two_factor_expires_at < now()) { $user->resetTwoFactorCode(); auth()->logout(); return redirect()->route('login') ->withStatus(__('The two factor code has expired. Please login again.')); } if (! $request->is('verify*')) { return redirect()->route('verify.index'); } } return $next($request); }}
If you're not familiar with how Middleware works, read the official Laravel documentation. But basically, it’s a class that performs some actions to usually restrict accessing some page or function.
So, in our case, we check if there is a two-factor code set. If it is, we check if it isn't expired. If it is expired, we reset it and redirect back to the login form. If it's still active, we redirect back to the verification form.
There is a new method resetTwoFactorCode()
. Add it to the app/Models/User.php
, it should look like this:
public function resetTwoFactorCode(): void{ $this->timestamps = false; $this->two_factor_code = null; $this->two_factor_expires_at = null; $this->save();}
Next, we need to register our middleware in app/Http/Kernel.php
by giving an "alias" name, let's call it twofactor
:
class Kernel extends HttpKernel{ protected $routeMiddleware = [ 'auth' => \App\Http\Middleware\Authenticate::class, // ... 'twofactor' => \App\Http\Middleware\TwoFactorMiddleware::class, ];}
Now, we can assign our twofactor
Middleware to the routes in routes/web.php
. Since Laravel Breeze has only one protected route /dashboard
, we will add our Middleware to that route:
Route::get('/dashboard', function () { return view('dashboard');})->middleware(['auth', 'twofactor'])->name('dashboard');
Now, our dashboard is protected by two-factor authentication.
Verification Page Controller & View
At this point, any request to any URL will redirect to the code verification page. Time to build it - both the page, and its processing method.
We start with generating the Controller:
php artisan make:controller TwoFactorController
Then, we add three routes leading to the new Controller:
routes/web.php:
Route::middleware(['auth', 'twofactor'])->group(function () { Route::get('verify/resend', [TwoFactorController::class, 'resend'])->name('verify.resend'); Route::resource('verify', TwoFactorController::class)->only(['index', 'store']);});
And all the logic in the Controller looks like this.
app/Http/Controllers/Auth/TwoFactorController:
class TwoFactorController extends Controller{ public function index(): View { return view('auth.twoFactor'); } public function store(Request $request): ValidationException|RedirectResponse { $request->validate([ 'two_factor_code' => ['integer', 'required'], ]); $user = auth()->user(); if ($request->input('two_factor_code') !== $user->two_factor_code) { throw ValidationException::withMessages([ 'two_factor_code' => __('The two factor code you have entered does not match'), ]); } $user->resetTwoFactorCode(); return redirect()->to(RouteServiceProvider::HOME); } public function resend(): RedirectResponse { $user = auth()->user(); $user->generateTwoFactorCode(); $user->notify(new SendTwoFactorCode()); return redirect()->back()->withStatus(__('The two factor code has been sent again')); }}
Three methods here:
-
index()
method just shows the form. - Then, this form is submitted with POST request to the
store()
method to verify the code - And lastly,
resend()
method re-generates and re-sends new code.
The form itself just uses the default layout and components from Laravel Breeze, the code looks like this:
resources/views/auth/twoFactor.blade.php:
<x-guest-layout> <x-auth-card> <x-slot name="logo"> <a href="/"> <x-application-logo class="h-20 w-20 fill-current text-gray-500" /> </a> </x-slot> <div class="mb-4 text-sm text-gray-600"> {{ new Illuminate\Support\HtmlString(__("You have received an email which contains two factor login code. If you haven't received it, press <a class=\"hover:underline\" href=\":url\">here</a>.", ['url' => route('verify.resend')])) }} </div> <!-- Session Status --> <x-auth-session-status class="mb-4" :status="session('status')" /> <form method="POST" action="{{ route('verify.store') }}"> @csrf <div> <x-input-label for="two_factor_code" :value="__('Two factor code')" /> <x-text-input id="two_factor_code" class="mt-1 block w-full" type="text" name="two_factor_code" required autofocus /> <x-input-error :messages="$errors->get('two_factor_code')" class="mt-2" /> </div> <div class="mt-4 flex justify-end"> <x-primary-button> {{ __('Verify') }} </x-primary-button> </div> </form> </x-auth-card></x-guest-layout>
And that's it with two-factor authentication using email. Now, what about SMS?
Send Verification Code Using SMS
The logic of generating the code stays identical. We will just send the code via different Notification channel.
We will make the Notification method configurable: in the config/auth.php
, add new array values:
'two_factor' => [ 'via' => ['mail'],],
That via
value must be an array: in our case, it either can be mail
or vonage
. For sending SMS we will use Vonage(formerly known as Nexmo), so that's why one of the options is called this way.
For sending notifications via Vonage, we need to install a laravel/vonage-notification-channel
package:
composer require laravel/vonage-notification-channel
Don't forget to set up the environment variables for Vonage.
Next, we need to edit our Notification class.
app/Notifications/SendTwoFactorCode.php:
class SendTwoFactorCode extends Notification{ public function via($notifiable): array { return ['mail']; return config('auth.two_factor.via'); } public function toMail(User $notifiable): MailMessage { return (new MailMessage) ->line("Your two factor code is {$notifiable->two_factor_code}") ->action('Verify Here', route('verify.index')) ->line('The code will expire in 10 minutes') ->line('If you have not tried to login, ignore this message.'); } public function toVonage($notifiable): VonageMessage { return (new VonageMessage()) ->content("Your two factor code is {$notifiable->two_factor_code}"); } }
Here we change via()
method to use the value from config, and also we add a new toVonage()
which will send an SMS message using Vonage provider.
This way we can easily change the method in the config file, and Laravel will do its magic.
Of course, we need to have the users phone number - where to send the SMS. For that, we will quickly create a new DB field phone_number
in the users
table, and let users enter it when registering.
database/migrations/2014_10_12_000000_create_users_table.php:
public function up(){ Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); $table->string('password'); $table->string('phone_number') $table->rememberToken(); $table->string('two_factor_code')->nullable(); $table->dateTime('two_factor_expires_at')->nullable(); $table->timestamps(); });}
app/Models/User.php:
class User extends Authenticatable{ protected $fillable = [ 'name', 'email', 'password', 'phone_number', 'two_factor_code', 'two_factor_expires_at', ]; public function routeNotificationForVonage($notification) { return $this->phone_number; }
In the User
Model, we also add the routeNotificationForVonage()
method which laravel/vonage-notification-channel
package uses to get number where to send SMS to.
resources/views/auth/register.blade.php:
// ... Other Fields<!-- Phone Number --><div class="mt-4"> <x-input-label for="phone_number" :value="__('Phone Number')" /> <x-text-input id="phone_number" class="block mt-1 w-full" type="text" name="phone_number" :value="old('phone_number')" required autofocus /> <x-input-error :messages="$errors->get('phone_number')" class="mt-2" /></div>// ... Other Fields
app/Http/Controllers/Auth/RegisteredUserController.php:
class RegisteredUserController extends Controller{ public function store(Request $request) { $request->validate([ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 'phone_number' => ['required'], 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'phone_number' => $request->phone_number, 'password' => Hash::make($request->password), ]); event(new Registered($user)); Auth::login($user); return redirect(RouteServiceProvider::HOME); }}
And that's it, now your application is more secure!
In the future, you can add other Notification channels pretty easily. For some methods, you can check Laravel Notification Channels page.
Hi Povilas.
First, quality stuff, as always. It worked at first try.
Comparing this approach with the Jetstream 2FA, wich uses some authenticator app, like google or microsoft, witch approach do you recommend for a company webapp?
Jetstream works with external 2FA providers, which is more reliable, as some people consider email/SMS as not-true 2FA.
So if you care more about "technically correct" security, probably Jetstream way is more recommended, but also much more complex to implement and require your users to install authenticator apps.
Hi mr. Povilas.
Thank you for your psots and cources.
I'm using 2FA but instead of storing the OTP and its expiration date in the database, I create cach value and life time of it and when user login the value will forgit as:
is that correct? is it better to store it in the database?.
thank you in advance.
Not sure, I haven't tried it. But technically, yes, cache is another storage mechanism, like database, so should be fine.
Hi Povilas,
Thank you for your helpful post on two-factor authentication. I have a question: after I verify my code and am redirected to the dashboard, if I manually hit the verify route again, the two-factor authentication code page appears again even though I have already verified.
Thanks
You need to make some kind of check then. Checking if verify fields are empty won't fit here. Maybe add a new field to the DB and while it is set to true user is verified.
Hello Povilas, I like the simplicity of this approach, but... In most systems, users are prompted with an option to select between various authentications forms, SMS, Email or Google authenticator for example, which can be done by simple tweaks in the system, but from what I see in the code (I haven't tested it) is that the middleware can cause issues and some other things are not handled, for instance:
If user 1 is logged in and they went through the code authentication step, and at the same time, some other user, using the same credentials, the first user will be prompted to enter verification code, basically this approach prohibits 2 users from using the same account (many cases multiple users in a company can use the same account to log in), I think in this case it's better to use a session to store the codes no? Also another thing, to save on SMS cost specifically, instead of prompting the users for code everytime they login, the browser and IP should be saved somewhere in the DB, if a user logs in from the same browser, on the same machine in the same location, they should be able to proceed without the verification step cause it becomes an overkill. What do you think?
Hi (sorry, not Povilas here :) )
When you add a 2fa - in theory you must log everyone out on that account. This is a security practice to follow. Just imagine, you have a bad guy logged into your account - and you add 2FA. This didn't kill the session, so he can still do whatever he wants inside...
Same thing with SMS - that's for people to decide how they trust the proccess. For example, if you are working in a sensitive field - each login might require 2FA. But if that's a blog - then definitely you need to remember the browser.
But again, as always - these things depend... So can't give you a "this is the way to do it" type of an answer, sorry
Thanks for the reply Modestas! I somewhat agree with what you say, but as you mentioned, there is no right way to do something, it all "depends" Big enterprises like Airlines for example, they give you access to their portal, but they give you one single account to be used by multiple people, when you enable 2FA on that account, whoever tries to log in, they'll need to ask you for the OTP, which makes sense, but storing the OTP in the database, will prevent this from happening. Regarding logging out users, I don't necessarily agree with that, a use can't log in unless you willingly give them an OTP to use, if for no matter what reason, you gave the OTP to the wrong person and they gained access, you have the option to log everyone out of the account, I believe JetStream works this way.
But that's my point - in some cases you might want to store the 2FA information (browser, IP, mac, whatever) to remember that user has confirmed it. In other cases, you do need that additional security level to simply ask for 2FA at all logins. And usually it depends on the IT security practices (for example, if you want to be in compliance with some sort of audit).
As for the logging out - I was talking more about the case, where you did not have 2FA and added it. That point should log out all other sessions. But if you have 2FA - then yeah, it makes sense to keep the sessions active, as the logged in browsers "should" be trusted.
In any case, this is very "it depends" and I've seen them done both ways. I even did it both ways myself, as I had to comply with certification proccess. But which is best - I'm not sure. You can probably skip the 2FA requirement as long as you are storing the IP or something and once new IP is used - you ask for 2FA again. That way, in theory, you limit hacking posibility :)