Reader Stacks

Passwordless Login in Laravel With a One-Time Code (OTP)

A minimal OTP login flow built on top of Laravel's own auth system — generate a code, email or text it, verify it, then log the user in — without reaching for a full package if you don't need one.

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.

Topics: Authentication & Access Control