2FA Login with Laravel and Nexmo
Published on May 18, 2021

This post originally appeared on michaelheap.com before Michael joined the Nexmo team!

I recently wrote about bootstrapping Laravel with user authentication and how easy it is (seriously, it takes less than 5 minutes). That provides us with a great starting point for our applications, but then I stumbled across this post about integrating two factor authentication with Google Authenticator and started thinking about Nexmo Verify.

I recently integrated Verify into a chatbot without any issues, and I thought that it could be a useful thing to integrate into my Laravel login flow.

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

This tutorial also uses a virtual phone number. To purchase one, go to Numbers > Buy Numbers and search for one that meets your needs.

Collecting the user's phone number

We need to collect the user's phone number - we can't send them a verification text without it. We could collect this after the user has registered, but I've decided to collect it at registration time instead.

The first thing we need to do is alter the users table so that there is a field ready to store the user's phone number. To do this, let's create a new migration to alter our users table:

php artisan make:migration add_users_phone_number

This creates a file in the database/migrations folder called <current time>_add_users_phone_number.php. Open up that file and replace it's contents with the following:


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

class AddUsersPhoneNumber extends Migration
     * Run the migrations.
     * @return void
    public function up()
        Schema::table('users', function (Blueprint $table) {

     * Reverse the migrations.
     * @return void
    public function down()
        Schema::table('users', function (Blueprint $table) {

This migration adds a column named phone_number when run, and drops the column when rolled back. Apply it now by running php artisan migrate in your terminal.

Next, we need to add a text input to our registration form for the user to provide their phone number. Edit resources/views/auth/register.blade.php and add the following to the bottom of the form just before the submit button:

<div class="form-group{{ $errors->has('phone_number') ? ' has-error' : '' }}">
    <label for="name" class="col-md-4 control-label">Phone Number</label>
    <div class="col-md-6">
        <input id="name" type="tel" class="form-control" name="phone_number" value="{{ old('phone_number') }}" required="" autofocus="">

     @if ($errors-&gt;has('phone_number'))
        <span class="help-block">
            <strong>{{ $errors-&gt;first('phone_number') }}</strong>

If we visit http://localhost:8000/register now, we should see the phone number field at the bottom of our registration form. We're almost there, but there's still one key part missing - we don't actually save the number that the user provides to our new field in the database.

Laravel keeps all of it's logic for registering a user in the app/Http/Controllers/Auth/RegisterController.php file. Open it up and take a look - you should see a validator method and a create method. We'll need to change both of these to save our user's phone number.

Let's start with the validator method. We need to add a new entry for phone_number to make sure that the number provided is valid. I've chosen to be quite strict with my validation rules, requiring that it is exactly 12 characters long and unique across all users - you may choose to be less strict. After adding a validation rule, your validator method should look similar to the following:

return Validator::make($data, [
    'name' =&gt; 'required|max:255',
    'email' =&gt; 'required|email|max:255|unique:users',
    'password' =&gt; 'required|min:6|confirmed',
    'phone_number' =&gt;; 'required|size:12|unique:users',

Once that data has passed the validation rules we specified, we need to store it in the database. To do this, we edit the create method and add an line that saves our phone number. All of the incoming request data is available in the $data variable, so it's as simple as adding a single line:

return User::create([
    'name' =&gt; $data['name'],
    'email' =&gt; $data['email'],
    'password' =&gt; bcrypt($data['password']),
    'phone_number' =&gt;; $data['phone_number']

If we try and add a user now, it won't work as expected. This is due to a safety feature in Laravel that prevents mass assignment of properties to a class. We haven't informed our User class that phone_number is a valid field, so it'll reject our request to save it. To solve this issue, edit app/User.php and add phone_number to the $fillable array:

protected $fillable = [
    'name', 'email', 'password', 'phone_number'

After making this change, feel free to register an account via the register page and log in to our application.

Adding Nexmo Verify

Now that we have the user's phone number, we're in a position to start implementing our Verify logic. Laravel runs the user's login request through app/Http/Controllers/Auth/LoginController.php to find out if the credentials provided are valid or not. If the credentials are valid, Laravel will then look for an authenticated method in the LoginController. If the method exists it will execute the logic in there. This is where we will add our two factor authentication logic.

Open up app/Http/Controllers/Auth/LoginController.php and add the following to the top next to the other use declarations:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Contracts\Auth\Authenticatable;

We need these three use statements to be able to type hint our authenticated method, which we should add next. Add the following to the LoginController class:

public function authenticated(Request $request, Authenticatable $user)
    $request-&gt;session()-&gt;put('verify:user:id', $user-&gt;id);
    // @TODO: Send the Verify SMS here
    return redirect('verify');

This code will log the user out again, storing their user ID in the session so that we know which user they tried to log in as. Once the verify request has been completed, we will use this ID to log the user in again automatically.

Triggering a Verify request

You may have noticed that there is a @TODO in there to add the Verify SMS logic. We don't currently have a way to send an SMS via Nexmo yet, so let's take care of that next. Thankfully, Nexmo have a Laravel package that makes this nice and easy for us. Following the README in that project, we install both the Nexmo client and the Laravel service provider with Composer:

composer require nexmo/client @beta composer require nexmo/laravel 1.0.0-beta3

After it's installed, we need to tell Laravel that our client exists. We need to edit two sections in config/app.php to do this - providers and aliases.

Add the following to providers:


Add the following to aliases:

'Nexmo' =&gt; \Nexmo\Laravel\Facade\Nexmo::class

Finally, we need to run php artisan vendor:publish to generate our Nexmo configuration file. Once we've run this command, we can edit config/nexmo.php and provide our API credentials in api_key and api_secret. We can either provide them directly here, or we can use the .env similar to the database configuration file. I'm going to use the .env file, so I've changed config/nexmo.php so that it contains the following:

'api_key' =&gt; env('NEXMO_KEY', ''),
'api_secret' =&gt; env('NEXMO_SECRET', ''),

Then in .env, I've added two entries at the bottom of the file - NEXMO_KEY and NEXMO_SECRET:


Now that the Nexmo client is configured, we can go back to app/Http/Controllers/Auth/LoginController.php and implement our notification system. Replace the @TODO comment that we left with the following:

$verification = Nexmo::verify()-&gt;start([
    'number' =&gt; $user-&gt;phone_number,
    'brand'  =&gt; 'Laravel Demo'
$request-&gt;session()-&gt;put('verify:request_id', $verification-&gt;getRequestId());

This will trigger a verify request via Nexmo to the phone number that we have on record for that user. We'll also need to add use Nexmo; to the top of the file so that our facade is available. Once you've done that, you'll be able to log in and trigger a verify request - but don't do that yet! We don't have a way for the user to provide their verification code, so you won't be able to confirm your identity.

Verifying the request

At the end of LoginController::authenticated we redirect the user to a /verify url. It's time to register that route with Laravel and write an implementation for it.

Open routes/web.php and add the following to the bottom of it:

Route::get('/verify', 'VerifyController@show')-&gt;name('verify');
Route::post('/verify', 'VerifyController@verify')-&gt;name('verify');

This registers two routes (a GET and a POST to /verify) that we will use to verify a user's code. We've told Laravel that it should call the show and verify methods on the VerifyController for these requests, so we should generate the controller using artisan:

php artisan make:controller VerifyController

This will create a file at app/Http/Controllers/VerifyController.php - you should replace its contents with the following:


namespace App\Http\Controllers;

use Auth;
use Nexmo;
use Illuminate\Http\Request;

class VerifyController extends Controller
    public function show(Request $request) {
        return view('verify');

    public function verify(Request $request) {
        return 'Not Implemented';

This is enough to show the `verify` view when someone makes a `GET` request to `/verify`. Once again, this file doesn't exist yet, so let's create it at `resources/views/verify.blade.php` with the following contents: ```blok {"type":"codeBlock","props":{"lang":"php","code":"@extends('layouts.app')%0A%0A@section('content')%0A%3Cdiv%20class=%22container%22%3E%0A%20%20%20%20%3Cdiv%20class=%22row%22%3E%0A%20%20%20%20%20%20%20%20%3Cdiv%20class=%22col-md-8%20col-md-offset-2%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cdiv%20class=%22panel%20panel-default%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cdiv%20class=%22panel-heading%22%3EVerify%3C/div%3E%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cdiv%20class=%22panel-body%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cform%20class=%22form-horizontal%22%20role=%22form%22%20method=%22POST%22%20action=%22%7B%7B%20route('verify')%20%7D%7D%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7B%7B%20csrf_field()%20%7D%7D%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cdiv%20class=%22form-group%7B%7B%20$errors-%3Ehas('code')%20?%20'%20has-error'%20:%20''%20%7D%7D%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Clabel%20for=%22code%22%20class=%22col-md-4%20control-label%22%3ECode%3C/label%3E%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cdiv%20class=%22col-md-6%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cinput%20id=%22code%22%20type=%22number%22%20class=%22form-control%22%20name=%22code%22%20value=%22%7B%7B%20old('code')%20%7D%7D%22%20required=%22%22%20autofocus=%22%22%3E%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20@if%20($errors-&gt;has('code'))%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cspan%20class=%22help-block%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cstrong%3E%7B%7B%20$errors-&gt;first('code')%20%7D%7D%3C/strong%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/span%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20@endif%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/div%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/div%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cdiv%20class=%22form-group%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cdiv%20class=%22col-md-6%20col-md-offset-4%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cbutton%20type=%22submit%22%20class=%22btn%20btn-primary%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20Verify%20Account%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/button%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/div%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/div%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/form%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/div%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3C/div%3E%0A%20%20%20%20%20%20%20%20%3C/div%3E%0A%20%20%20%20%3C/div%3E%0A%3C/div%3E%0A@endsection%0A"}} ``` There's a lot of HTML there, but all it does is show a single form input with a submit button. You can visit the [verify page](http://localhost:8000/verify) to see it now. Now that we have our page to input our Verify code, all that's left to do is verify the code provided with Nexmo. Replace your `verify` method in `VerifyController` with the following code. This method validates that our incoming data is 4 characters long (Nexmo verification codes can be 4 or 6 characters long, I'm working with 4) then checks the provided code with Nexmo. If it doesn't validate, an exception is thrown and we return an error to the user. Otherwise, we fetch the user ID from the session, log the user in and redirect to the home controller. ```blok {"type":"codeBlock","props":{"lang":"php","code":"public%20function%20verify(Request%20$request)%20%7B%0A%20%20%20%20$this-&gt;validate($request,%20%5B%0A%20%20%20%20%20%20%20%20'code'%20=&gt;%20'size:4',%0A%20%20%20%20%5D);%0A%0A%20%20%20%20try%20%7B%0A%20%20%20%20%20%20%20%20Nexmo::verify()-&gt;check(%0A%20%20%20%20%20%20%20%20%20%20%20%20$request-&gt;session()-&gt;get('verify:request_id'),%0A%20%20%20%20%20%20%20%20%20%20%20%20$request-&gt;code%0A%20%20%20%20%20%20%20%20);%0A%20%20%20%20%20%20%20%20Auth::loginUsingId($request-&gt;session()-&gt;pull('verify:user:id'));%0A%20%20%20%20%20%20%20%20return%20redirect('/home');%0A%20%20%20%20%7D%20catch%20(Nexmo%5CClient%5CException%5CRequest%20$e)%20%7B%0A%20%20%20%20%20%20%20%20return%20redirect()-&gt;back()-&gt;withErrors(%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20'code'%20=&gt;%20$e-&gt;getMessage()%0A%20%20%20%20%20%20%20%20%5D);%0A%0A%20%20%20%20%7D%0A%7D%0A"}} ``` At this point, our integration should be working end to end. If you save all of your changes and try logging in, you should be redirected to the `verify` page and receive a text message with your verification code. Enter the code and you'll be logged in as expected. Congratulations! You just integrated two factor authentication with Nexmo Verify in to your Laravel application. ### Tidying up the rough edges Whilst it works, there are still some rough edges to work out. For example, a user can log in a second time without confirming the first Verify request. They can also get to the `/verify` page without having an active Verify request. Finally, we don't verify their identity after registration - only after they log out and try to log in again. We're not going to solve these issues in this post - I'll leave them as an exercise for you!

Michael HeapVonage Alumni

Michael is a polyglot software engineer, committed to reducing complexity in systems and making them more predictable. Working with a variety of languages and tools, he shares his technical expertise to audiences all around the world at user groups and conferences. Day to day, Michael is a former developer advocate at Vonage, where he spent his time learning, teaching and writing about all kinds of technology.

Ready to start building?

Experience seamless connectivity, real-time messaging, and crystal-clear voice and video calls-all at your fingertips.

Subscribe to Our Developer Newsletter

Subscribe to our monthly newsletter to receive our latest updates on tutorials, releases, and events. No spam.