Complete Guide: How to Implement OTP-Based Login in Laravel

Complete Guide: How to Implement OTP-Based Login in Laravel

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.

What You'll Learn in This Tutorial

  • ✅ Complete OTP authentication system setup
  • ✅ Database design for secure token storage
  • ✅ Email integration with multiple providers
  • ✅ Security best practices and rate limiting
  • ✅ Mobile-responsive frontend implementation
  • ✅ Troubleshooting common issues

Prerequisites: What You Need Before Starting

Before implementing OTP authentication, ensure your development environment meets these requirements:

Required Software:

  • PHP 8.1+ (Laravel 10/11 compatibility)
  • MySQL 8.0 or PostgreSQL 13+
  • Composer package manager

Knowledge Requirements:

  • Basic Laravel MVC understanding
  • Database migrations and Eloquent models
  • Email service configuration
  • Basic PHP and JavaScript

Step 1: Laravel Project Setup and Configuration

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.


Step 2: Database Configuration and Security Setup

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.


Step 3: Email Service Integration (Multiple Options)

OTP delivery requires a reliable email service. Here are the most popular options for 2025:

Option A: Gmail SMTP (Development)


MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=your-app-password
MAIL_ENCRYPTION=tls
[email protected]

Option B: SendGrid (Production Recommended)


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]

Option C: Amazon SES (Enterprise)


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]

Step 4: Creating OTP Model and Migration

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

Why these fields matter:

  • purpose: Distinguishes between login, password reset, or email verification OTPs
  • expires_at: Ensures tokens are time-limited for security
  • attempts: Tracks failed verification attempts to prevent brute force
  • ip_address: Additional security layer for fraud detection

Step 5: Building the OTP Model with Security Features

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();
    }
}

Step 6: Creating the OTP Authentication Controller

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'));
        });
    }
}

Key Security Features:

  • Rate limiting prevents spam and brute force attacks
  • OTP tokens expire after 10 minutes
  • Maximum 3 verification attempts per token
  • IP address tracking for fraud detection
  • Automatic cleanup of old tokens

Step 7: Creating User-Friendly Views

OTP Login Form

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

OTP Verification Form

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

Email Template

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>



Step 8: Setting Up Routes

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');
});

Security Best Practices Checklist

  • Token Expiration: OTPs expire within 10 minutes
  • Rate Limiting: Prevent brute force attacks
  • IP Tracking: Monitor suspicious activities
  • Secure Generation: Use cryptographically secure random numbers
  • Database Security: Use proper indexing and foreign keys
  • Email Security: Use TLS encryption for email delivery
  • Session Management: Clear sensitive session data
  • Input Validation: Validate all user inputs
  • Error Handling: Don't reveal sensitive information
  • Audit Trail: Log important authentication events

Performance Optimization Tips

Database Optimization


// 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');
});

Frequently Asked Questions


Q: Can I use SMS instead of email for OTPs?

A: Yes! Integrate services like Twilio or AWS SNS for SMS delivery. Replace the email sending logic with SMS API calls.

Q: How do I customize OTP length?

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);
}

Q: Is this implementation secure for production?

A: Yes, when following all security best practices mentioned in this guide. Ensure you implement rate limiting, use HTTPS, and monitor for suspicious activities.

Q: How do I handle OTP for password resets?

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),
]);

Q: Can I integrate this with social login?

A: Absolutely! Use OTP as an additional security layer after social authentication for sensitive operations.


Conclusion: Next Steps

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.

What's Next:

  • Implement SMS-based OTPs for enhanced security
  • Add biometric authentication for mobile apps
  • Set up comprehensive monitoring and alerting
  • Consider implementing WebAuthn for passwordless authentication
  • Add fraud detection and risk scoring

Remember: Security is an ongoing process. Regularly update your Laravel installation, monitor authentication logs, and stay informed about the latest security best practices.

Do you accept cookies?

We use cookies to enhance your browsing experience. By using this site, you consent to our cookie policy.

More