Creating a password-less, Medium-style, email-only authentication system in Laravel

Matt Stauffer
8 minute read

Recently I was working on a project where one of our major pain points was users' passwords. Users were added to the application by administrators, so they didn't have passwords when they were first added, and forcing them to set and remember passwords was a big hitch on the project's usability.

So, we decided to try out a Medium/Slack-inspired password-less login. If you've never had the chance to work with this, the login system works like this: enter your email address on the login page, get emailed a login link, click the link, and now you're logged in. Access to your email address proves your identity without the need for a password.

Let's build one together.

New app and make:auth

First we create our Laravel app and scaffold the authentication system:

laravel new medium-login
cd medium-login
php artisan make:auth

We now have a series of new authentication-related files, including the login and registration pages. Let's start by tweaking those files.

Modify the login and registration pages

The login and registration pages are pretty good, but we need to drop the password fields from each.

Open up the login page at resources/views/auth/login.blade.php and delete the entire password form group (label, input, and wrapping <div>). Save and close.

Open up the registration page at resources/views/auth/register.blade.php and delete the password and password-reset form groups there too. Save and close.

Later you'll probably want to give some instructions on both pages describing how our authentication will work, and drop the links to password resets, but for right now this should be good enough.

Modify the registration routes

Now, we need to update the route that the login and registration forms are pointing to. Let's head over to the AuthController and see what we have.

First, we'll notice the validator method, which returns a validator that expects a password field. This is the validator for the account registration process, so let's get rid of the password there.

The function should end up looking like this:

// app/http/Controllers/Auth/AuthController.php
protected function validator(array $data)
{
    return Validator::make($data, [
        'name' => 'required|max:255',
        'email' => 'required|email|max:255|unique:users',
    ]);
}

And we'll do the same thing for the create method, which is also used for registration:

// app/http/Controllers/Auth/AuthController.php
protected function create(array $data)
{
    return User::create([
        'name' => $data['name'],
        'email' => $data['email'],
    ]);
}

Override the login route

But you can see that there are no methods here for logging in users. These are hidden in the AuthenticatesAndRegistersUsers trait, which just gives you the AuthenticatesUsers trait and the RegistersUsers trait. You can go to the AuthenticatesUsers trait and find, finally, that the method that logs users in is named login.

Everything that's happening there is predicated around a passworded login, though, so let's override that method in its entirety.

The goal of our new method will be be to trigger an email to the user prompting them to verify their login. Let's go to the AuthController and add a login method to override the one in AuthenticatesUsers.

// app/http/Controllers/Auth/AuthController.php
public function login(Request $request)
{
    // validate that this is a real email address
    // send off a login email
    // show the users a view saying "check your email"
}

Validate that this is a real email address

First, let's validate their email address. That's pretty easy:

$this->validate($request, ['email' => 'required|email|exists:users']);

Send off a login email

Next, we need to send off an email prompting them to log in. This will take a bit more work.

Creating the structure for generating and validating email login tokens

If you're familiar with the shape of the password_reset database structure, we'll be creating something very similar. Every time someone tries to log in, we'll need to add an entry to a table that captures their email address, a unique token we just created (and will send in the email as a part of the URL), and the created date for expiration purposes.

In the end we'll use an entry in this table to generate (and verify) a URL like this: myapp.com/auth/email-authenticate/09ajfpoib23li4ub123p984h1234. We'll expire this login after a certain time, and associate that URL with a particular user, so we need to track email, token, and created_at for each entry in that table.

So, let's create a migration for it:

php artisan make:migration create_email_logins_table --create=email_logins

And let's add a few fields in there:

Schema::create('email_logins', function (Blueprint $table) {
    $table->string('email')->index();
    $table->string('token')->index();
    $table->timestamps();
});

Note: If you wanted, you could use a foreign key id column instead of the email—there are a few reasons that might be better, but I just like email. It's up to you.

Now, let's create the model.

php artisan make:model EmailLogin

Edit the file (app/EmailLogin.php) and make it simple for us to create an instance with the right properties:

class EmailLogin extends Model
{
    public $fillable = ['email', 'token'];
}

And then we'll want to associate each with a user, and since in this particular example we're tracking user by email, not id, we have to manually link each table's email column:

class EmailLogin extends Model
{
    public $fillable = ['email', 'token'];

    public function user()
    {
        return $this->hasOne(\App\User::class, 'email', 'email');
    }
}

Creating a token

Now we're ready to create the email. We're going to want to send an email to the end user with a URL that contains the unique token we generated earlier.

First, let's figure out how we're going to handle the creation and storage of those tokens. We need to create an instance of EmailLogin, so let's start there:

public function login()
{
    $this->validate($request, ['email' => 'required|email|exists:users']);

    $emailLogin = EmailLogin::createForEmail($request->input('email'));
}

Let's add that method to EmailLogin:

class EmailLogin extends Model
{
    ...
    public static function createForEmail($email)
    {
        return self::create([
            'email' => $email,
            'token' => str_random(20)
        ]);
    }
}

We're now generating a random token and creating an instance of of the EmailToken class with it, and then returning it back.

Building the URL to send the users in the email

Now, we need to use that EmailToken to build a URL that we can send to our user in the email.

public function login()
{
    $this->validate($request, ['email' => 'required|email|exists:users']);

    $emailLogin = EmailLogin::createForEmail($request->input('email'));

    $url = route('auth.email-authenticate', [
        'token' => $emailLogin->token
    ]);
}

Let's create the route for it:

// app/Http/routes.php
Route::get('auth/email-authenticate/{token}', [
    'as' => 'auth.email-authenticate',
    'uses' => 'Auth\AuthController@authenticateEmail'
]);

... and set up a controller method to fulfill that route:

class AuthController
{
    ...
    public function authenticateEmail($token)
    {
        $emailLogin = EmailLogin::validFromToken($token);

        Auth::login($emailLogin->user);

        return redirect('home');
    }
}

... and let's make that method validFromToken work:

class EmailLogin
{
    ...
    public static function validFromToken($token)
    {
        return self::where('token', $token)
            ->where('created_at', '>', Carbon::parse('-15 minutes'))
            ->firstOrFail();
    }

We now have an incoming route that, given a valid token that hasn't expired, logs the user in and redirects them to home. Let's send this email.

Sending the email

Let's add the "send mail" call to our controller method:

public function login()
{
    ...
    Mail::send('auth.emails.email-login', ['url' => $url], function ($m) use ($request) {
        $m->from('noreply@myapp.com', 'MyApp');
        $m->to($request->input('email'))->subject('MyApp Login');
    });

... and create that email template:

<!-- resources/views/auth/emails/email-login.blade.php -->
Log in to MyApp here: <a href="{{ $url }}">{{ $url }}</a>

Returning the temporary view

You can create a view any way you want, but in general you just need to say "Hey, we send you an email, go check for it." That's all.

return 'Login email sent. Go check your email.';

Putting the pieces together

So, let's take a look at our system. We have a new login method on our AuthController:

public function login(Request $request)
{
    $this->validate($request, ['email' => 'required|exists:users']);

    $emailLogin = EmailLogin::createForEmail($request->input('email'));

    $url = route('auth.email-authenticate', [
        'token' => $emailLogin->token
    ]);

    Mail::send('auth.emails.email-login', ['url' => $url], function ($m) use ($request) {
        $m->from('noreply@myapp.com', 'MyApp');
        $m->to($request->input('email'))->subject('MyApp login');
    });

    return 'Login email sent. Go check your email.';
}

We've created a few new views. We updated old views to drop the password fields. We created a new route at /auth/email-authenticate. And we've created an EmailLogin migration and class to support all of these needs.

That's everything!

And done! Put all of these pieces together and you have a fully-functional, passwordless login system.

When your users sign up, they only need to provide their email address. When they log in, they only need to provide their email address. No more forgotten passwords. Boom.