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…
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?
- Add nonce to URL
- One-time use of URL (for example, using Cache (or DB) to invalidate used URL)
- Switch to generated token, instead of Signed URLs (again, using Cache or DB)
- 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!
Top comments (0)