Easiest Passwordless Login in Laravel without external packages

Let's create a Passwordless Login in our Laravel app with magic link via email

·

4 min read

Easiest Passwordless Login in Laravel without external packages

In this fast tutorial, we will create the easiest Passwordless Login in Laravel, using Signed URLs.

Signed URLs are available in Laravel since version 5.6, but in my experience they aren’t known enough.

📌 We assume you have the login view with a form with only the email field.

We need just 2 routes, that is…

Passwordless Login steps

Route 1: Post user email

This route:

  • receive the user email

  • create a Signed URL

  • and send it to user via email (or other channel).

// routes/web.php

Route::post('/passwordless/login', function(Request $request) {
    // please, move me to a Controller ;)

    $request->validate([
        'email' => 'required|email'
    ]);

    $user = User::query()
        ->where('email', $request->email)
        ->first();

    if ($user) {
        $passwordlessUrl = \URL::temporarySignedRoute(
            'passwordless.login',
            now()->addMinutes(10),
            ['user' => $user->id]
        );

        // notify user via email or other channel...
        $user->notify(new PasswordlessNotification($passwordlessUrl));
    }
    // else... we send always a success message to avoid any "info extraction"

    return back()->with('success', 'You have an email!');
});

Route 2: check signature and login

Here, we have the route that login the user:

  • it receive the user id (the model is loaded automatically by Model Binding)

  • it validate signature (🎯 it’s really important! 😎)

  • and finally login the user.

// routes/web.php

Route::get('/passwordless/login/{user}', function(Request $request, User $user) {
    // please, move me to a Controller ;)

    if (! $request->hasValidSignature()) {
        abort(401);
    }

    \Auth::login($user);

    return redirect('/');

})->name('passwordless.login');

…and that’s it!

The PasswordlessNotification class

In the Route 1, we assumed that you have a PasswordNotification class.

For simply do that:

php artisan make:notification PasswordlessNotification

And then:

// app/Notifications/PasswordlessNotification.php

class PasswordlessNotification extends Notification
{
    use Queueable;

    public function __construct(
        public string $passwordlessUrl
    ) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Your magic link to login')

            ->line("Hi {$notifiable->firstname}")
            ->line('you can login by the link below:')
            ->action('Login', $this->passwordlessUrl)

            ->line('Thank you for using our application!');
    }
}

Some important considerations

First of all: is it secure?

The short answer is: it depends.

I'll state the obvious, but it's important to be clear: the tutorial above doesn't cover every situation!

In some simple situations this may be enough, but in many others it is not!

On the other hand, it says "Easiest" in the title!

Some question that you need to ask yourself:

  • What is the context?

  • What is the level of risk to manage?

  • What additional security mechanisms are needed?

Do you hate Passwordless Login in general?

If the answer is true, this tutorial is not for you.

After years of development, I have met many developers (even good ones!) who don't even want to consider systems of this type.

I think that everything needs to be put into context: booking on the barbershop app is not the same as accessing home banking!

If the barbershop app asked me for 2FA with an external app... I would actually laugh. But worse, if the barbershop app asked me to enter a password, I'd be worried. What is the probability that it would store my password securely? I've seen so many DBs with passwords saved in MD5 or so.

How can we improve the tutorial above?

  1. Add nonce to URL

  2. One-time use of URL (for example, using Cache (or DB) to invalidate used URL)

  3. Switch to generated token, instead of Signed URLs (again, using Cache or DB)

  4. And many other… add your own in the comments!


Now, let’s see improvement 1, 2 and 3.

1. Add nonce to URL

In “Route 1”, let’s add a parameter (hello in this example) containing a random string.

$passwordlessUrl = \URL::temporarySignedRoute(
    'passwordless.login',
    now()->addMinutes(10),
    ['user' => $user->id, 'hello' => \Str::random(64)]
);

2. One-time use of URL

In “Route 2”, let’s add full URL in Cache (just 10 minutes, equals to the Signed URL duration) and check if it is already in Cache, before login.

// routes/web.php

Route::get('/passwordless/login/{user}', function(Request $request, User $user) {
    // please, move me to a Controller ;)

    if (! $request->hasValidSignature()) {
        abort(401);
    }

    $onetimeCacheKey = "pwl.url.{$request->fullUrl()}";

    if (\Cache::has($onetimeCacheKey)) {
        abort(401);
    }

    \Auth::login($user);

    \Cache::put($onetimeCacheKey, 1, 10 * 60);

    return redirect('/');

})->name('passwordless.login');

3. Switch to generated token

In this case, we change the approach: we move to a generated token, instead of Signed URL.

And then, “Route 1":

Route::post('/passwordless/login', function(Request $request) {
    // please, move me to a Controller ;)

    $request->validate([
        'email' => 'required|email'
    ]);

    $user = User::query()
        ->where('email', $request->email)
        ->first();

    if ($user) {
        $token = \Str::random(64);

        \Cache::put("pwl.tkn.{$token}", $user->id, 10 * 60);

        $passwordlessUrl = route('passwordless.login', [
            'token' => $token
        ]);

        // notify user via email or other channel...
        $user->notify(new PasswordlessNotification($passwordlessUrl));
    }
    // else... we send always a success message to avoid any "info extraction"

    return back()->with('success', 'You have an email!');
});

Finally, “Route 2":

Route::get('/passwordless/login/{token}', function(Request $request, string $token) {
    // please, move me to a Controller ;)

    $tokenCacheKey = "pwl.tkn.{$token}";

    $userId = \Cache::get($tokenCacheKey);

    abort_if($userId == null, 401);

    $user = User::findOrFail($userId);

    \Auth::login($user);

    \Cache::forget($tokenCacheKey);

    return redirect('/');

})->name('passwordless.login');

✸ Enjoy your coding!

If you liked this post, don't forget to Subscribe to my newsletter!