Laravel Two-Factor Authentication via SMS (2FA Guide)

Laravel Two-Factor Authentication via SMS (2FA Guide)
Laravel SMS Team
Laravel SMS Team
June 21, 2026

Adding SMS-based two-factor authentication to your Laravel application significantly improves account security. This guide walks through a complete implementation — from database changes to the verification flow — using Laravel’s built-in authentication system.

Overview of 2FA Methods

Two-factor authentication adds a second verification layer beyond the password. Common second factors include:

  • SMS codes — A one-time code sent via text message
  • TOTP — Time-based one-time passwords (Google Authenticator, Authy)
  • Email codes — Codes sent to the user’s email
  • Hardware keys — FIDO2/WebAuthn security keys

This guide focuses on SMS-based 2FA with a TOTP fallback option for users who cannot receive SMS.

Adding Phone Number to Users Table

First, add phone number and 2FA-related columns:

<?php

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->string('phone_number', 20)->nullable()->after('email');
            $table->boolean('two_factor_enabled')->default(false)->after('password');
            $table->text('two_factor_secret')->nullable()->after('two_factor_enabled');
            $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret');
            $table->timestamp('phone_verified_at')->nullable()->after('two_factor_recovery_codes');
        });
    }
};
---

Update the `User` model:

```php
<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    protected $fillable = [
        'name', 'email', 'password',
        'phone_number', 'two_factor_enabled',
    ];

    protected $hidden = [
        'password', 'two_factor_secret', 'two_factor_recovery_codes',
    ];

    protected function casts(): array
    {
        return [
            'two_factor_enabled' => 'boolean',
            'phone_verified_at' => 'datetime',
        ];
    }

    public function hasVerifiedPhone(): bool
    {
        return !is_null($this->phone_verified_at);
    }

    public function isTwoFactorEnabled(): bool
    {
        return $this->two_factor_enabled && $this->hasVerifiedPhone();
    }
}
---

## Generating Verification Codes

Create a service to generate and manage 2FA codes:

```php
<?php

namespace App\Services;

use App\Models\User;
use Illuminate\Support\Facades\Cache;

class TwoFactorService
{
    public function generateCode(User $user): string
    {
        $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);

        Cache::put(
            "2fa:code:{$user->id}",
            [
                'code' => $code,
                'attempts' => 0,
            ],
            now()->addMinutes(10)
        );

        return $code;
    }

    public function verifyCode(User $user, string $code): bool
    {
        $cached = Cache::get("2fa:code:{$user->id}");

        if (!$cached) {
            return false;
        }

        if ($cached['attempts'] >= 5) {
            Cache::forget("2fa:code:{$user->id}");
            return false;
        }

        if (!hash_equals((string) $cached['code'], $code)) {
            Cache::increment("2fa:code:{$user->id}.attempts");
            return false;
        }

        Cache::forget("2fa:code:{$user->id}");
        return true;
    }
}
---

## SMS Notification for 2FA Codes

Send the code via SMS using a notification:

```php
<?php

namespace App\Notifications;

use App\Services\TwoFactorService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class TwoFactorCode extends Notification implements ShouldQueue
{
    use Queueable;

    public string $code;

    public function __construct(string $code)
    {
        $this->code = $code;
    }

    public function via(object $notifiable): array
    {
        return $notifiable->phone_number ? ['sms'] : ['mail'];
    }

    public function toSms(object $notifiable): string
    {
        return "Your verification code is: {$this->code}. It expires in 10 minutes.";
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Your Two-Factor Authentication Code')
            ->line("Your verification code is: **{$this->code}**")
            ->line('This code expires in 10 minutes.')
            ->line('If you did not request this, ignore this message.');
    }
}
---

## Middleware to Enforce 2FA

Create middleware that checks whether the user needs to complete 2FA:

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class RequireTwoFactor
{
    public function handle(Request $request, Closure $next): mixed
    {
        $user = Auth::user();

        if (!$user || !$user->isTwoFactorEnabled()) {
            return $next($request);
        }

        if (!$request->session()->has('2fa_verified')) {
            return redirect()->route('2fa.verify');
        }

        return $next($request);
    }
}
---

Register it in `Kernel.php`:

```php
protected $routeMiddleware = [
    '2fa' => \App\Http\Middleware\RequireTwoFactor::class,
];
---

Apply it to routes that need 2FA:

```php
Route::middleware(['auth', '2fa'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
    Route::get('/settings', [SettingsController::class, 'edit']);
});
---

## Verification Flow

Create a controller to handle the 2FA challenge:

```php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Notifications\TwoFactorCode;
use App\Services\TwoFactorService;
use Illuminate\Http\Request;

class TwoFactorController extends Controller
{
    public function __construct(
        protected TwoFactorService $twoFactorService
    ) {}

    public function showChallengeForm(Request $request)
    {
        $user = $request->user();

        if (!$user->isTwoFactorEnabled()) {
            return redirect()->intended('/dashboard');
        }

        if (!$request->session()->has('2fa:code_sent')) {
            $code = $this->twoFactorService->generateCode($user);
            $user->notify(new TwoFactorCode($code));
            $request->session()->put('2fa:code_sent', true);
        }

        return view('auth.two-factor-challenge');
    }

    public function verify(Request $request)
    {
        $request->validate([
            'code' => 'required|string|size:6',
        ]);

        $user = $request->user();

        if ($this->twoFactorService->verifyCode($user, $request->code)) {
            $request->session()->put('2fa_verified', true);
            $request->session()->forget('2fa:code_sent');

            return redirect()->intended('/dashboard');
        }

        return back()->withErrors([
            'code' => 'The verification code is invalid or expired.',
        ]);
    }
}
---

Routes:

```php
Route::middleware('auth')->group(function () {
    Route::get('/2fa/verify', [TwoFactorController::class, 'showChallengeForm'])
        ->name('2fa.verify');
    Route::post('/2fa/verify', [TwoFactorController::class, 'verify'])
        ->name('2fa.verify.submit');
});
---

### Resending Codes

```php
public function resend(Request $request)
{
    $request->session()->forget('2fa:code_sent');

    return redirect()->route('2fa.verify');
}
---

## Remembering Trusted Devices

Allow users to skip 2FA on trusted devices:

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class RequireTwoFactor
{
    public function handle(Request $request, Closure $next): mixed
    {
        $user = Auth::user();

        if (!$user || !$user->isTwoFactorEnabled()) {
            return $next($request);
        }

        // Check trusted device cookie
        if ($request->cookie('2fa_trusted_' . $user->id)) {
            return $next($request);
        }

        if (!$request->session()->has('2fa_verified')) {
            return redirect()->route('2fa.verify');
        }

        return $next($request);
    }
}
---

Add a "Remember this device" checkbox to the challenge form:

```php
public function verify(Request $request)
{
    $request->validate([
        'code' => 'required|string|size:6',
    ]);

    $user = $request->user();

    if ($this->twoFactorService->verifyCode($user, $request->code)) {
        $request->session()->put('2fa_verified', true);
        $request->session()->forget('2fa:code_sent');

        // Set trusted device cookie (30 days)
        if ($request->boolean('remember')) {
            cookie()->queue(
                cookie('2fa_trusted_' . $user->id, true, 60 * 24 * 30)
            );
        }

        return redirect()->intended('/dashboard');
    }

    return back()->withErrors([
        'code' => 'The verification code is invalid or expired.',
    ]);
}
---

## Backup Codes for Lost Phone Access

Generate recovery codes during 2FA setup:

```php
use Illuminate\Support\Str;

public function generateRecoveryCodes(): array
{
    $codes = [];

    for ($i = 0; $i < 8; $i++) {
        $codes[] = strtoupper(
            implode('-', [
                Str::random(4),
                Str::random(4),
                Str::random(4),
            ])
        );
    }

    return $codes;
}
---

Store hashed recovery codes on the user:

```php
public function enableTwoFactor(User $user): array
{
    $codes = $this->generateRecoveryCodes();

    $user->update([
        'two_factor_enabled' => true,
        'two_factor_recovery_codes' => json_encode(
            array_map(fn ($code) => bcrypt($code), $codes)
        ),
    ]);

    return $codes; // Return plain-text codes to display once
}
---

Handle recovery code verification:

```php
public function verifyWithRecoveryCode(User $user, string $code): bool
{
    $recoveryCodes = json_decode($user->two_factor_recovery_codes, true) ?? [];

    foreach ($recoveryCodes as $index => $hashedCode) {
        if (password_verify($code, $hashedCode)) {
            // Remove used code
            unset($recoveryCodes[$index]);
            $user->update([
                'two_factor_recovery_codes' => json_encode(array_values($recoveryCodes)),
            ]);
            return true;
        }
    }

    return false;
}
---

## Related Articles

- [Twilio SMS Integration with Laravel (Complete Guide 2026)](/blog/laravel-twilio-sms-integration)
- [Laravel Custom SMS Channel for Notifications](/blog/laravel-custom-sms-channel)
- [SMS Gateway Integration in Laravel (Multi-Provider Guide)](/blog/laravel-sms-gateway-integration)