Implement Email-based 2FA in Laravel
Add an extra security layer to your Laravel application by sending one-time passwords (OTPs) to users' email addresses. This guide explains step by step how to set up the migration, OTP generation, mail class, verification flow, and a simple Blade form.
Process Overview
- User logs in with email and password.
- The server generates an OTP, stores it in the
email_otpstable, and sends it via email. - User enters the OTP on the verification page.
- The server validates the OTP (expiry and usage flag) and logs the user in if valid.
1. Migration for OTPs
Create a migration create_email_otps_table and run php artisan migrate.
// migration: create_email_otps_table
public function up()
{
Schema::create('email_otps', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('otp');
$table->timestamp('expires_at');
$table->boolean('is_used')->default(false);
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
2. Generate OTP on Login
After login, generate the OTP, save it, send it via email, and log the user out until verification.
use App\Models\EmailOtp;
use Illuminate\Support\Facades\Mail;
use App\Mail\SendOtpMail;
use Carbon\Carbon;
public function login(Request $request)
{
$credentials = $request->only('email', 'password');
if (Auth::attempt($credentials)) {
$user = Auth::user();
// Generate OTP
$otp = rand(100000, 999999);
EmailOtp::create([
'user_id' => $user->id,
'otp' => $otp,
'expires_at' => Carbon::now()->addMinutes(5),
]);
// Send OTP
Mail::to($user->email)->send(new SendOtpMail($otp));
Auth::logout();
return redirect()->route('verify.otp')->with('info', 'OTP has been sent to your email.');
}
return back()->withErrors(['email' => 'Invalid credentials']);
}
3. Mail Class for OTP
Create a mail class with php artisan make:mail SendOtpMail and use a simple view:
public $otp;
public function __construct($otp) { $this->otp = $otp; }
public function build()
{
return $this->subject('Your OTP Code')
->view('emails.otp')
->with(['otp' => $this->otp]);
}
Your OTP Code
Use the following OTP to complete login:
{{ $otp }}
This code will expire in 5 minutes.
4. Routes & OTP Verification
Add the following routes:
Route::get('/verify-otp', [AuthController::class, 'showOtpForm'])->name('verify.otp');
Route::post('/verify-otp', [AuthController::class, 'verifyOtp'])->name('verify.otp.submit');
Verification logic:
public function verifyOtp(Request $request)
{
$request->validate(['otp' => 'required|numeric']);
$otpRecord = EmailOtp::where('otp', $request->otp)
->where('expires_at', '>', now())
->where('is_used', false)
->first();
if ($otpRecord) {
$otpRecord->update(['is_used' => true]);
Auth::loginUsingId($otpRecord->user_id);
return redirect()->route('dashboard')->with('success', 'Login successful');
}
return back()->withErrors(['otp' => 'Invalid or expired OTP']);
}
5. OTP Form (Blade)
Notes / Best Practices
- OTP expiry should be between 3–10 minutes (example uses 5 minutes).
- Throttle resend attempts (rate limiting) to prevent abuse.
- Consider storing hashed OTPs for additional security (hash before saving and validate with
Hash::check). - Log OTP attempts and notify users in case of multiple failed attempts.
For production, you may also want to add Resend OTP functionality, rate-limiting middleware, and secure OTP hashing.