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.
Two-factor authentication adds a second verification layer beyond the password. Common second factors include:
This guide focuses on SMS-based 2FA with a TOTP fallback option for users who cannot receive SMS.
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)