This article was peer reviewed by Younes Rafie and Wern Ancheta. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!
Authentication is something that has evolved over the years. We have seen it change from email – password combination to social authentication, and finally password-less authentication. Actually, more like an “email only” authentication. In the case of a password-less login, the app assumes that you will get the login link from your inbox if the email provided is indeed yours.
The general flow in a password-less login system is as follows:
- Users visit the login page
- They type in their email address and confirm
- A link is sent to their email
- Upon clicking the link, they are redirected back to the app and logged in
- The link is disabled
This comes in handy when you can’t remember your password for an app, but you do remember the email you signed up with. Even Slack employs this technique.
In this tutorial, we are going to implement such a system in a Laravel app. The complete code can be found here.
Creating the App
Let’s start by generating a new Laravel app. I am using Laravel 5.2 in this tutorial:
composer create-project laravel/laravel passwordless-laravel 5.2.*
If you have an existing Laravel project with users and passwords, worry not – we won’t be interfering with the normal auth flow, just creating a layer on top of what is already there. Users will still have the option of logging in with passwords.
Database Setup
Next, we have to set up our MySQL database before running any migrations.
Open your .env
file in the root directory and pass in the hostname, username, and database name:
[...]
DB_CONNECTION=mysql
DB_HOST=localhost
DB_DATABASE=passwordless-app
DB_USERNAME=username
DB_PASSWORD=
[...]
If you’re using our Homestead Improved box, the database / username / password combination is homestead
, homestead
, secret
.
Scaffolding Auth
One great thing that Laravel introduced in version 5.2 is the ability to add a pre-made authentication layer with just a single command. Let’s do that:
php artisan make:auth
This command scaffolds everything we need for authentication i.e the Views, Controllers, and Routes.
Migrations
If we look inside database/migrations
, we notice that the generated Laravel app came with migrations for creating the users
table and password_resets
table.
We won’t alter anything since we still want our app to have the normal auth flow.
To create the tables, run:
php artisan migrate
We can now serve the app and users should be able to sign up and log in using the links in the nav.
Changing the Login Link
Next, we want to change the login link to redirect users to a custom login view where users will be submitting their email addresses without a password.
Navigate to resources/views/layouts/app.blade.php
. That’s where we find the nav partial. Change the line with the login link (right below the conditional to check if the user is logged out) to this:
resources/views/layouts/app.blade.php
[...]
@if (Auth::guest())
<li><a href="{{ url('/login/magiclink') }}">Login</a></li>
<li><a href="{{ url('/register') }}">Register</a></li>
[...]
When a user tries to access a protected route when not logged in, they should be taken to our new custom login view instead of the normal one. This behavior is specified in the authenticate middleware. We’ll have to tweak that:
app/Http/Middleware/Authenticate.php
class Authenticate
{
[...]
public function handle($request, Closure $next, $guard = null)
{
if (Auth::guard($guard)->guest()) {
if ($request->ajax() || $request->wantsJson()) {
return response('Unauthorized.', 401);
} else {
return redirect()->guest('login/magiclink');
}
}
return $next($request);
}
[...]
Notice inside the else block
we’ve changed the redirect to point to login/magiclink
instead of the normal login
.
Creating the Magic Login Controller, View, and Routes
Our next step is to create the MagicLoginController
inside our Auth folder:
php artisan make:controller Auth\\MagicLoginController
Then the route to display our custom login page:
app/Http/routes.php
[...]
Route::get('/login/magiclink', 'Auth\MagicLoginController@show');
Let’s update our MagicLoginController
to include a show action:
app/Http/Controllers/Auth/MagicLoginController.php
class MagicLoginController extends Controller
{
[...]
public function show()
{
return view('auth.magic.login');
}
[...]
}
For the new login view, we are going to borrow the normal login view but remove the password field. We’ll also change the form’s post URL to point to \login\magiclink
.
Let’s create a magic folder inside the views/auth
folder to hold this new view:
mkdir resources/views/auth/magic
touch resources/views/auth/magic/login.blade.php
Let’s update our newly created view to this:
resources/views/auth/magic/login.blade.php
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Login</div>
<div class="panel-body">
<form class="form-horizontal" role="form" method="POST" action="{{ url('/login/magiclink') }}">
{{ csrf_field() }}
<div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
<label for="email" class="col-md-4 control-label">E-Mail Address</label>
<div class="col-md-6">
<input id="email" type="email" class="form-control" name="email" value="{{ old('email') }}" required autofocus>
@if ($errors->has('email'))
<span class="help-block">
<strong>{{ $errors->first('email') }}</strong>
</span>
@endif
</div>
</div>
<div class="form-group">
<div class="col-md-6 col-md-offset-4">
<div class="checkbox">
<label>
<input type="checkbox" name="remember"> Remember Me
</label>
</div>
</div>
</div>
<div class="form-group">
<div class="col-md-8 col-md-offset-4">
<button type="submit" class="btn btn-primary">
Send magic link
</button>
<a href="{{ url('/login') }}" class="btn btn-link">Login with password instead</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
We will leave an option to log in with a password since users may still opt for the password login. So if users click on login from the nav, they’ll be taken to a login view that looks like this:
Generating Tokens and Associating Them with Users
Our next step is to generate tokens and associate them with users. This happens when one submits their email in order to log in.
Let’s start by creating a route to handle the posting action of the login form:
app/Http/routes.php
[...]
Route::post('/login/magiclink', 'Auth\MagicLoginController@sendToken');
Then, we add a controller method called sendToken
inside the MagicLoginController
. This method will validate the email address, associate a token with a user, send off a login email and flash a message notifying the user to check their email:
app/Http/Controllers/Auth/MagicLoginController.php
class MagicLoginController extends Controller
{
[...]
/**
* Validate that the email has a valid format and exists in the users table
* in the email column
*/
public function sendToken(Request $request)
{
$this->validate($request, [
'email' => 'required|email|max:255|exists:users,email'
]);
//will add methods to send off a login email and a flash message later
}
[...]
}
Now that we have a valid email address, we can send off a login email to the user. But before the email is sent, we have to generate a token for the user trying to log in. I don’t want to have all my method’s in the MagicLoginController
and thus we’ll create a users-token
model to handle some of these methods.
php artisan make:model UserToken -m
This command will make us both the model and the migration. We need to tweak the migration a bit and add user_id
and token
columns. Open the newly generated migration file and change the up
method to this:
database/migrations/{timestamp}_create_user_tokens_table.php
[...]
public function up()
{
Schema::create('user_tokens', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id');
$table->string('token');
$table->timestamps();
});
}
[...]
Then run the migrate
Artisan command:
php artisan migrate
In the UserToken
model, we need to add the user_id
and token
as part of the mass assignable attributes. We should also define the relationship this model has with the User
model and vice-versa:
App/UserToken.php
[...]
class UserToken extends Model
{
protected $fillable = ['user_id', 'token'];
/**
* A token belongs to a registered user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}
Then inside App/User.php
specify that a User
can only have one token associated with them:
App/User.php
class User extends Model
{
[...]
/**
* A user has only one token.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function token()
{
return $this->hasOne(UserToken::class);
}
}
Let’s now generate the token. First, we need to retrieve a user object by their email before creating the token. Create a method in the User
model called getUserByEmail
to handle this functionality:
App/User.php
class User extends Model
{
[...]
protected static function getUserByEmail($value)
{
$user = self::where('email', $value)->first();
return $user;
}
[...]
}
We have to pull in the namespaces forUser
and UserToken
classes into our MagicLoginController
in order to be able to call the methods in these classes from our controller:
app/Http/Controllers/Auth/MagicLoginController.php
[...]
use App\User;
use App\UserToken;
[...]
class MagicLoginController extends Controller
{
[...]
public function sendToken(Request $request)
{
//after validation
[...]
$user = User::getUserByEmail($request->get('email'));
if (!user) {
return redirect('/login/magiclink')->with('error', 'User not foud. PLease sign up');
}
UserToken::create([
'user_id' => $user->id,
'token' => str_random(50)
]);
}
[...]
}
In the code block above, we are retrieving a user object based on the submitted email. Before getting to this point note we had to validate the presence of the submitted email address in the users
table. But in the case where someone bypassed the validation and submitted an email that didn’t exist within our records, we will flash a message asking them to sign up.
Once we have the user object, we generate a token for them.
Emailing the Token
We can now email the generated token to the user in the form of a URL. First, we’ll have to require the Mail
Facade in our model to help us with the email sending functionality.
In this tutorial, however, we won’t be sending any real emails. Just confirming that the app can send an email in the logs. To do this, navigate to your .env
file and under the mail section set MAIL_DRIVER=log
. Also, we won’t be creating email views; just sending a raw email from our UserToken
class.
Let’s create a method in our UserToken
model called sendEmail
to handle this functionality. The URL which is a combination of the token
, email address
and remember me
value will be generated inside this method:
app/UserToken.php
[...]
use Illuminate\Support\Facades\Mail;
[...]
class UserToken extends Model
{
[...]
public static function sendMail($request)
{
//grab user by the submitted email
$user = User::getUserByEmail($request->get('email'));
if(!$user) {
return redirect('/login/magiclink')->with('error', 'User not foud. PLease sign up');
}
$url = url('/login/magiclink/' . $user->token->token . '?' . http_build_query([
'remember' => $request->get('remember'),
'email' => $request->get('email'),
]));
Mail::raw(
"<a href='{$url}'>{$url}</a>",
function ($message) use ($user) {
$message->to($user->email)
->subject('Click the magic link to login');
}
);
}
[...]
}
While generating the URL, we’ll use PHP’s http_build_query
function to help us make a query from the array of options passed. In our case it’s email and remember me value. After generating URL, we then mail it to the user.
Time to update our MagicLoginController
and call the sendEmail
method:
app/Http/Controllers/Auth/MagicLoginController.php
class MagicLoginController extends Controller
{
[...]
public function sendToken(Request $request)
{
$this->validate($request, [
'email' => 'required|email|max:255|exists:users,email'
]);
UserToken::storeToken($request);
UserToken::sendMail($request);
return back()->with('success', 'We\'ve sent you a magic link! The link expires in 5 minutes');
}
[...]
}
We are also going to implement some basic flash messaging for notifications. In your resources/views/layouts/app.blade.php
insert this block right above your content
since flash messages show up at the top before any other content:
resources/views/layouts/app.blade.php
[...]
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
@include ('layouts.partials._notifications')
</div>
</div>
</div>
@yield('content')
[...]
Then create the notifications partial:
resources/views/layouts/partials/_notifications.blade.php
@if (session('success'))
<div class="alert alert-success">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="alert alert-danger">
{{ session('error') }}
</div>
@endif
In the partial, we have used the session
helper to help us with different notification colors based on the session status i.e. success
or error
.
At this point, we are able to send emails. We can try it out by logging in with a valid email address, then navigating to the laravel.log
file. We should be able to see the email containing the URL at the bottom of the logs.
Next, we want to validate the token and log the user in. We don’t want cases where a token that was sent out 3 days ago can still be used to log in.
Token Validation and Authentication
Now that we have the URL, let’s create a route and controller action to handle what happens when one clicks on the URL from their email:
app/Http/routes.php
[...]
Route::get('/login/magiclink/{token}', 'Auth\MagicLoginController@authenticate');
Let’s create the authenticate
action in the MagicLoginController
. It’s inside this method that we will authenticate the user. We are going to pull in the token into the authenticate
method through Route Model Binding. We will then grab the user from the token. Note that we have to pull in the Auth facade
in the controller to make it possible to use Auth
methods:
app/Http/Controllers/Auth/MagicLoginController.php
[...]
use Auth;
[...]
class MagicLoginController extends Controller
{
[...]
public function authenticate(Request $request, UserToken $token)
{
Auth::login($token->user, $request->remember);
$token->delete();
return redirect('home');
}
[...]
}
Then in the UserToken class
set the route key name that we expect. In our case, it’s the token:
App/UserToken.php
[...]
public function getRouteKeyName()
{
return 'token';
}
[...]
And there we have it. Users can now log in. Note that after logging the user in, we delete the token since we don’t want to fill the user_tokens
table with used tokens.
Our next step is checking if the token is still valid. For this app, we are going to make the magic link expire after 5 minutes. We will require the Carbon library to help us check the time difference between the token creation time and the current time.
In ourUserToken
model, we are going to create two methods: isExpired
and belongsToEmail
to check the validity of the token. Note, the belongsToEmail
validation is just an extra precaution making sure the token indeed belongs to that email address:
App/UserToken.php
[...]
use Carbon\Carbon;
[...]
class UserToken extends Model
{
[...]
//Make sure that 5 minutes have not elapsed since the token was created
public function isExpired()
{
return $this->created_at->diffInMinutes(Carbon::now()) > 5;
}
//Make sure the token indeed belongs to the user with that email address
public function belongsToUser($email)
{
$user = User::getUserByEmail($email);
if(!$user || $user->token == null) {
//if no record was found or record found does not have a token
return false;
}
//if record found has a token that matches what was sent in the email
return ($this->token === $user->token->token);
}
[...]
}
Let’s call the methods on the token instance in the authenticate
method in the MagicLoginController
:
app/Http/Controllers/Auth/MagicLoginController.php
class MagicLoginController extends Controller
{
[...]
public function authenticate(Request $request, UserToken $token)
{
if ($token->isExpired()) {
$token->delete();
return redirect('/login/magiclink')->with('error', 'That magic link has expired.');
}
if (!$token->belongsToUser($request->email)) {
$token->delete();
return redirect('/login/magiclink')->with('error', 'Invalid magic link.');
}
Auth::login($token->user, $request->get('remember'));
$token->delete();
return redirect('home');
}
[...]
}
Conclusion
We have successfully added password-less login on top of the normal auth flow. Some may argue this takes longer than the normal password login, but so does using a password manager.
Passwordless systems wouldn’t work everywhere though, if you have short session timeout periods or expect users to log in frequently it could become frustrating. Fortunately, that affects very few sites.
Don’t you think it’s time you gave users an alternative way to log in your next project?
Please leave your comments and questions below, and remember to share this post with your friends and colleagues if you liked it!
You can also check Magic Link boilerplates that are essentially codebases with authentication and other features already handled.
Frequently Asked Questions (FAQs) about Magic Login Links
How Do Magic Login Links Enhance Security?
Magic login links enhance security by eliminating the need for passwords, which are often the weakest link in a security chain. Instead of relying on a password that can be easily guessed, stolen, or phished, magic login links use a unique, one-time-use URL sent directly to the user’s email. This link expires after a certain period or after it’s been used, making it a more secure alternative to traditional passwords.
What Happens if the Magic Login Link Email is Intercepted?
If the magic login link email is intercepted, the interceptor would gain access to the account. However, this risk is mitigated by the fact that the link is time-sensitive and expires after a short period. Additionally, the user is notified when the link is used, allowing them to take immediate action if it wasn’t them.
Can Magic Login Links be Used for All Types of Websites?
Yes, magic login links can be used for all types of websites. They are particularly useful for websites that handle sensitive information, such as e-commerce sites, banking sites, and any site that requires user authentication.
How Do Magic Login Links Improve User Experience?
Magic login links improve user experience by simplifying the login process. Users no longer need to remember complex passwords or go through the process of resetting forgotten passwords. They simply click on the link sent to their email and they’re logged in.
Are Magic Login Links Compatible with Two-Factor Authentication?
Yes, magic login links are compatible with two-factor authentication. The magic link serves as the first factor (something the user has – their email account), and a second factor can be added for additional security, such as a fingerprint or a unique code sent via SMS.
How Can I Implement Magic Login Links on My Website?
Implementing magic login links on your website involves modifying your site’s authentication system. There are several plugins and libraries available that can help with this, such as the Magic Login plugin for WordPress or the MagicLink library for Laravel.
What are the Drawbacks of Using Magic Login Links?
One potential drawback of using magic login links is that it relies on the user’s email being secure. If the user’s email account is compromised, so is their access to any site using magic login links. Additionally, some users may find the process of checking their email for a link every time they want to log in inconvenient.
Can Magic Login Links be Used in Conjunction with Passwords?
Yes, magic login links can be used in conjunction with passwords. This provides an additional layer of security, as the user would need both the magic link and their password to log in.
How Long Do Magic Login Links Remain Valid?
The validity period of a magic login link can be set by the website administrator. Typically, they remain valid for a short period, such as 15 minutes to an hour, to ensure security.
Can Magic Login Links be Resent if They Expire?
Yes, if a magic login link expires before the user has a chance to use it, a new link can be requested and sent to the user’s email.
Chris is a software developer at Andela. He has worked with both Rails and Laravel and blogs to share a few tips. Chris also loves traveling.