Complete Guide: How to Implement OTP-Based Login in Laravel (2025 Updated)
User security has become more critical than ever. With cyber attacks increasing by 38% in 2024, traditional password-based authentication alone isn't enough to protect user accounts. OTP (One-Time Password) authentication adds an extra security layer that significantly reduces unauthorized access risks.
Quick Answer: OTP-based login in Laravel requires creating an OTP model, setting up email/SMS services, building verification controllers, and implementing secure token generation. This guide provides complete code examples and best practices for 2025.
Before implementing OTP authentication, ensure your development environment meets these requirements:
Let's create a new Laravel project optimized for secure authentication:
# Create new Laravel project
composer create-project laravel/laravel otp-auth-system
# Navigate to project directory
cd otp-auth-system
# Install security packages
composer require laravel/sanctum
Why this approach works: Starting with a fresh Laravel installation ensures we have the latest security updates and optimal configuration.
Proper database configuration is crucial for storing OTP tokens securely. Update your .env file:
# Database Configuration
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_otp_auth
DB_USERNAME=your_username
DB_PASSWORD=strong_password_here
# Security Enhancements
DB_CHARSET=utf8mb4
DB_COLLATION=utf8mb4_unicode_ci
Run initial migrations to set up the user table:
php artisan migrate
Security Note: Always use environment variables for sensitive configuration. Never hardcode credentials in your codebase.
OTP delivery requires a reliable email service. Here are the most popular options for 2025:
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=your-app-password
MAIL_ENCRYPTION=tls
[email protected]
MAIL_MAILER=smtp
MAIL_HOST=smtp.sendgrid.net
MAIL_PORT=587
MAIL_USERNAME=apikey
MAIL_PASSWORD=your-sendgrid-api-key
MAIL_ENCRYPTION=tls
[email protected]
MAIL_MAILER=ses
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_DEFAULT_REGION=us-east-1
[email protected]
Generate the OTP model with migration:
php artisan make:model OtpToken -m
Update the migration file (database/migrations/xxxx_create_otp_tokens_table.php):
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('otp_tokens', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('token', 6)->index();
$table->string('purpose')->default('login'); // login, reset, verify
$table->timestamp('expires_at')->index();
$table->timestamp('used_at')->nullable();
$table->string('ip_address', 45)->nullable();
$table->integer('attempts')->default(0);
$table->timestamps();
// Foreign key constraints
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
// Composite indexes for performance
$table->index(['user_id', 'purpose']);
$table->index(['token', 'expires_at']);
});
}
public function down()
{
Schema::dropIfExists('otp_tokens');
}
};
Run the migration:
php artisan migrate
Update app/Models/OtpToken.php:
<!--?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OtpToken extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'token',
'purpose',
'expires_at',
'ip_address',
'attempts'
];
protected $casts = [
'expires_at' =--> 'datetime',
'used_at' => 'datetime',
];
/**
* Relationship with User model
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Check if OTP token is valid
*/
public function isValid(): bool
{
return $this->expires_at > now() &&
is_null($this->used_at) &&
$this->attempts < 3;
}
/**
* Mark token as used
*/
public function markAsUsed(): void
{
$this->update(['used_at' => now()]);
}
/**
* Increment failed attempts
*/
public function incrementAttempts(): void
{
$this->increment('attempts');
}
/**
* Generate secure OTP token
*/
public static function generateSecureToken(): string
{
return str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
}
Update app/Models/User.php to add relationship:
<!--?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\HasMany;
class User extends Authenticatable
{
// ... existing code ...
/**
* Relationship with OTP tokens
*/
public function otpTokens(): HasMany
{
return $this--->hasMany(OtpToken::class);
}
/**
* Get active OTP for specific purpose
*/
public function getActiveOtp(string $purpose = 'login'): ?OtpToken
{
return $this->otpTokens()
->where('purpose', $purpose)
->where('expires_at', '>', now())
->whereNull('used_at')
->first();
}
}
Generate the controller:
php artisan make:controller Auth/OtpAuthController
Implement the controller logic (app/Http/Controllers/Auth/OtpAuthController.php):
<!--?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\OtpToken;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
class OtpAuthController extends Controller
{
/**
* Show OTP login form
*/
public function showLoginForm()
{
return view('auth.otp-login');
}
/**
* Send OTP to user's email
*/
public function sendOtp(Request $request)
{
// Rate limiting
$key = 'send-otp:' . $request--->ip();
if (RateLimiter::tooManyAttempts($key, 3)) {
$seconds = RateLimiter::availableIn($key);
throw ValidationException::withMessages([
'email' => ['Too many OTP requests. Try again in ' . $seconds . ' seconds.']
]);
}
$request->validate([
'email' => 'required|email|exists:users,email'
]);
$user = User::where('email', $request->email)->first();
// Clear any existing active OTPs
$user->otpTokens()
->where('purpose', 'login')
->whereNull('used_at')
->delete();
// Generate new OTP
$otpToken = OtpToken::create([
'user_id' => $user->id,
'token' => OtpToken::generateSecureToken(),
'purpose' => 'login',
'expires_at' => now()->addMinutes(10),
'ip_address' => $request->ip()
]);
// Send OTP via email
$this->sendOtpEmail($user, $otpToken->token);
RateLimiter::hit($key, 300); // 5-minute window
return redirect()
->route('otp.verify')
->with('email', $user->email)
->with('success', 'OTP sent successfully to your email.');
}
/**
* Show OTP verification form
*/
public function showVerifyForm()
{
if (!session('email')) {
return redirect()->route('otp.login');
}
return view('auth.otp-verify');
}
/**
* Verify OTP and login user
*/
public function verifyOtp(Request $request)
{
$request->validate([
'email' => 'required|email|exists:users,email',
'otp' => 'required|digits:6',
]);
$user = User::where('email', $request->email)->first();
$otpToken = $user->getActiveOtp('login');
if (!$otpToken) {
throw ValidationException::withMessages([
'otp' => ['No active OTP found. Please request a new one.']
]);
}
if (!$otpToken->isValid()) {
throw ValidationException::withMessages([
'otp' => ['OTP has expired or exceeded maximum attempts.']
]);
}
if ($otpToken->token !== $request->otp) {
$otpToken->incrementAttempts();
throw ValidationException::withMessages([
'otp' => ['Invalid OTP code.']
]);
}
// OTP is valid - log in user
$otpToken->markAsUsed();
Auth::login($user, true);
// Clear session data
$request->session()->forget('email');
return redirect()
->intended(route('dashboard'))
->with('success', 'Welcome back! You have been logged in successfully.');
}
/**
* Resend OTP
*/
public function resendOtp(Request $request)
{
return $this->sendOtp($request);
}
/**
* Send OTP email
*/
private function sendOtpEmail(User $user, string $otp)
{
Mail::send('emails.otp', ['user' => $user, 'otp' => $otp], function ($message) use ($user) {
$message->to($user->email)
->subject('Your Login OTP - ' . config('app.name'));
});
}
}
Create resources/views/auth/otp-login.blade.php:
@extends('layouts.app')
@section('title', 'OTP Login')
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">🔐 Secure Login with OTP</h4>
</div>
<div class="card-body">
<p class="text-muted">Enter your email address to receive a secure one-time password.</p>
@if(session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
@csrf
<div class="form-group mb-3">
<label for="email" class="form-label">Email Address</label>
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="" placeholder="Enter your registered email" autocomplete="email">
@error('email')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
📧 Send OTP to Email
</button>
<div class="text-center mt-3">
<small class="text-muted">
Don't have an account?
<a href="{{ route('register') }}">Sign up here</a>
</small>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
Create resources/views/auth/otp-verify.blade.php:
@extends('layouts.app')
@section('title', 'Verify OTP')
@section('content')
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-header bg-success text-white">
<h4 class="mb-0">✅ Verify Your OTP</h4>
</div>
<div class="card-body">
<p class="text-muted">
We've sent a 6-digit code to <strong>{{ session('email') }}</strong>
</p>
@if(session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('otp.verify') }}">
@csrf
<input type="hidden" name="email" value="{{ session('email') }}">
<div class="form-group mb-3">
<label for="otp" class="form-label">Enter OTP Code</label>
<input type="text" class="form-control form-control-lg text-center @error('otp') is-invalid @enderror" id="otp" name="otp" maxlength="6" placeholder="000000" style="font-size: 1.5rem; letter-spacing: 0.5rem;" autocomplete="one-time-code">
@error('otp')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<button type="submit" class="btn btn-success btn-lg w-100 mb-3">
🔓 Verify & Login
</button>
</form>
<div class="text-center">
<form method="POST" action="{{ route('otp.resend') }}" class="d-inline">
@csrf
<input type="hidden" name="email" value="{{ session('email') }}">
<button type="submit" class="btn btn-link text-decoration-none">
🔄 Resend OTP
</button>
</form>
</div>
<div class="text-center mt-2">
<small class="text-muted">
OTP expires in 10 minutes. Check your spam folder if you don't see the email.
</small>
</div>
</div>
</div>
</div>
</div>
</div>
// Auto-format OTP input
document.getElementById('otp').addEventListener('input', function(e) {
let value = e.target.value.replace(/\D/g, '');
if (value.length <= 6) {
e.target.value = value;
}
});
// Auto-submit when 6 digits entered
document.getElementById('otp').addEventListener('input', function(e) {
if (e.target.value.length === 6) {
setTimeout(() => {
e.target.closest('form').submit();
}, 500);
}
});
@endsection
Create resources/views/emails/otp.blade.php:
Your OTP Code
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; background: #f8f9fa; }
.otp-code { font-size: 2rem; font-weight: bold; color: #007bff; text-align: center;
padding: 20px; background: white; border: 2px dashed #007bff;
margin: 20px 0; letter-spacing: 0.5rem; }
.footer { text-align: center; color: #666; font-size: 0.9rem; padding: 20px; }
<div class="container">
<div class="header">
<h1>🔐 Your Login OTP</h1>
</div>
<div class="content">
<h2>Hello {{ $user->name }},</h2>
<p>You requested to log in to your account. Please use the following One-Time Password (OTP) to complete your login:</p>
<div class="otp-code">{{ $otp }}</div>
<p><strong>Important Security Information:</strong></p>
<ul>
<li>This OTP is valid for 10 minutes only</li>
<li>Do not share this code with anyone</li>
<li>If you didn't request this, please secure your account immediately</li>
</ul>
<p>If you're having trouble, please contact our support team.</p>
<p>Best regards,
{{ config('app.name') }} Team</p>
</div>
<div class="footer">
<p>This email was sent to {{ $user->email }}. If you didn't request this login, please ignore this email.</p>
</div>
</div>
Add routes to routes/web.php:
<!--?php
use App\Http\Controllers\Auth\OtpAuthController;
use Illuminate\Support\Facades\Route;
// OTP Authentication Routes
Route::middleware('guest')--->group(function () {
Route::get('/otp/login', [OtpAuthController::class, 'showLoginForm'])->name('otp.login');
Route::post('/otp/send', [OtpAuthController::class, 'sendOtp'])->name('otp.send');
Route::get('/otp/verify', [OtpAuthController::class, 'showVerifyForm'])->name('otp.verify');
Route::post('/otp/verify', [OtpAuthController::class, 'verifyOtp'])->name('otp.verify');
Route::post('/otp/resend', [OtpAuthController::class, 'resendOtp'])->name('otp.resend');
});
// Protected Routes
Route::middleware('auth')->group(function () {
Route::get('/dashboard', function () {
return view('dashboard');
})->name('dashboard');
});
// Root redirect
Route::get('/', function () {
return redirect()->route('otp.login');
});
// Add indexes for better query performance
Schema::table('otp_tokens', function (Blueprint $table) {
$table->index(['user_id', 'purpose', 'expires_at']);
$table->index(['created_at']);
});
// Cache frequently accessed user data
$user = Cache::remember("user.{$email}", 300, function () use ($email) {
return User::where('email', $email)->first();
});
// Use queues for email sending
Mail::queue('emails.otp', ['user' => $user, 'otp' => $otp], function ($message) use ($user) {
$message->to($user->email)->subject('Your Login OTP');
});
A: Yes! Integrate services like Twilio or AWS SNS for SMS delivery. Replace the email sending logic with SMS API calls.
A: Modify the token generation method:
public static function generateSecureToken(int $length = 6): string
{
return str_pad(random_int(0, pow(10, $length) - 1), $length, '0', STR_PAD_LEFT);
}
A: Yes, when following all security best practices mentioned in this guide. Ensure you implement rate limiting, use HTTPS, and monitor for suspicious activities.
A: Use the same system with a different purpose:
OtpToken::create([
'user_id' => $user->id,
'token' => OtpToken::generateSecureToken(),
'purpose' => 'password_reset', // Different purpose
'expires_at' => now()->addMinutes(15),
]);
A: Absolutely! Use OTP as an additional security layer after social authentication for sensitive operations.
You've successfully implemented a secure OTP-based login system in Laravel! This authentication method significantly improves your application's security while providing a smooth user experience.
Remember: Security is an ongoing process. Regularly update your Laravel installation, monitor authentication logs, and stay informed about the latest security best practices.