Reader Stacks

OTP Login and Registration Without a Password in Laravel

Passwordless auth trades a stored password hash for a short-lived, single-use numeric code sent by email or SMS — the same pattern whether the endpoint is a traditional web form or a JSON API.

Passwordless (OTP-based) authentication sends a short-lived, single-use numeric code by email or SMS instead of relying on a stored password — the underlying pattern is identical whether it's exposed as a traditional web form or a JSON API endpoint.

Generating and storing the OTP

public function sendOtp(Request $request)
{
    $request->validate(['phone' => 'required|string']);

    $otp = random_int(100000, 999999);

    Cache::put('otp_'.$request->phone, $otp, now()->addMinutes(5));

    // Send via SMS gateway (Twilio, Vonage, etc.) or email
    Sms::send($request->phone, "Your verification code is: {$otp}");

    return response()->json(['message' => 'OTP sent.']);
}

Using the cache (with a 5-minute expiration) rather than a permanent database column to store the OTP is deliberate — it needs to be genuinely short-lived and automatically expire, which the cache's TTL handles without requiring a separate cleanup job.

Verifying the OTP and logging in

public function verifyOtp(Request $request)
{
    $request->validate([
        'phone' => 'required|string',
        'otp' => 'required|digits:6',
    ]);

    $cachedOtp = Cache::get('otp_'.$request->phone);

    if (! $cachedOtp || (int) $request->otp !== $cachedOtp) {
        return response()->json(['error' => 'Invalid or expired code.'], 422);
    }

    Cache::forget('otp_'.$request->phone);

    $user = User::firstOrCreate(
        ['phone' => $request->phone],
        ['name' => 'User', 'password' => Hash::make(Str::random(32))]
    );

    Auth::login($user);

    return response()->json(['message' => 'Logged in.', 'user' => $user]);
}

Cache::forget() immediately after a successful verification is what makes the OTP genuinely single-use — without it, the same code could be replayed again within its expiration window.

Why firstOrCreate() handles both login and registration in one call

firstOrCreate() is what makes this flow work for both new and returning users identically — an existing phone number logs the matching user in, while a new phone number transparently creates a new account, both through the exact same verification endpoint without needing separate registration logic.

Why a random password is still set on the new user

Setting password to a long random string (rather than leaving it null) satisfies the users table's typical NOT NULL constraint on that column and Laravel's `Authenticatable` expectations — the value itself is never meant to be used, since this user is expected to always authenticate via OTP going forward, not a traditional password login.

Rate-limiting OTP requests to prevent abuse

Route::post('/send-otp', [AuthController::class, 'sendOtp'])
    ->middleware('throttle:3,1'); // max 3 requests per minute per IP

Without rate limiting, the OTP endpoint could be abused to spam a phone number or email address with repeated codes, or as a vector for SMS-cost-based denial-of-service — Laravel's built-in throttle middleware is a straightforward first line of defense against this.

Adding an expiry countdown on the client

let remaining = 300; // 5 minutes in seconds
const timer = setInterval(() => {
    remaining--;
    document.getElementById('countdown').textContent = `${Math.floor(remaining / 60)}:${(remaining % 60).toString().padStart(2, '0')}`;
    if (remaining <= 0) clearInterval(timer);
}, 1000);