A one-time-code (OTP) login lets a user sign in with a short code sent to their email or phone instead of a password. Laravel doesn't ship this out of the box, but it's straightforward to build on top of the framework's existing auth and notification systems — you don't need a heavyweight package just for a basic version.
1. Generate and store a code
$code = random_int(100000, 999999);
$user->forceFill([
'otp_code' => Hash::make($code),
'otp_expires_at' => now()->addMinutes(10),
])->save();
Hash the stored code the same way you'd hash a password — if the codes table is ever exposed, plain 6-digit codes are trivially guessable, so they deserve the same treatment as a password, not casual plaintext storage.
2. Send it
Notification::send($user, new OtpCodeNotification($code));
Using Laravel's notification system means the same code path can send via mail, SMS (through a driver like Vonage or Twilio), or both, without duplicating delivery logic per channel.
3. Verify it
public function verify(Request $request)
{
$request->validate(['code' => 'required|digits:6']);
$user = User::where('email', $request->email)->first();
abort_unless(
$user
&& $user->otp_expires_at?->isFuture()
&& Hash::check($request->code, $user->otp_code),
422,
'Invalid or expired code'
);
$user->forceFill(['otp_code' => null, 'otp_expires_at' => null])->save();
Auth::login($user);
return redirect()->intended('/dashboard');
}
Rate limiting matters here more than on a normal login
A 6-digit code has only a million possible values — without rate limiting, it's brute-forceable in a realistic timeframe. Apply Laravel's built-in throttle middleware to the verification route, and expire codes aggressively (5–10 minutes is typical).
When to use a full package instead
If you need multi-factor auth alongside passwords (not instead of them), social login, or a broader auth surface, Fortify, Jetstream, or Breeze already provide a more complete foundation — a hand-rolled OTP flow like this is best suited for a genuinely OTP-only login, not as a component bolted onto a larger auth package without checking for conflicts first.