DEV Community

Cover image for Magic login links with Laravel
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Magic login links with Laravel

Written by Ozzie Neher ✏️

If you've ever used a site like Vercel or Medium, you've likely experienced a passwordless login before.

The flow typically goes like this: enter your email -> submit form -> email gets sent to you -> you click the link inside -> you're logged in.

It's a pretty convenient flow for everyone. The users don't have to remember a password with the website’s arbitrary ruleset, and the webmasters (do people still use that term?) don't have to worry about password leaks or if their encryption is good enough.

In this article we're going to explore how one might implement this flow using a standard Laravel installation.

We're going to assume that you have a working understanding of Laravel's MVC structure and that your environment has both Composer and PHP set up already.

Please note that the codeblocks in this article may not include the whole file for brevity.

Environment setup

Let's start off by creating a new Laravel 8 application:

$ composer create-project laravel/laravel magic-links
Enter fullscreen mode Exit fullscreen mode

Then we need to cd into our project and ensure we enter our database credentials. Make sure to create the database beforehand as well.

In my case, I'm using PostgreSQL and I do all of my configuration through TablePlus. Open up the .env file:

# .env
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=magic_link
DB_USERNAME=postgres
DB_PASSWORD=postgres
Enter fullscreen mode Exit fullscreen mode

Now our database is configured, but don't run the migrations yet! Let's take a look at the default user migration that Laravel created for us in database/migrations/2014_10_12_000000_create_users_table.php.

You'll see that the default user table contains a column for the password. Since we're doing passwordless auth, we can get rid of it:

public function up()
{
  Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamp('email_verified_at')->nullable();
    $table->rememberToken();
    $table->timestamps();
  });
}
Enter fullscreen mode Exit fullscreen mode

Go ahead and save the file after you delete that line. While we're cleaning things up, let's go ahead and delete the migration for the password reset table since it will be no use to us:

$ rm database/migrations/2014_10_12_100000_create_password_resets_table.php
Enter fullscreen mode Exit fullscreen mode

Our initial database schema is ready, so let's run our migrations:

$ php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Let's also remove the password attribute from the user model's $fillable array in app/Models/User.php since it no longer exists:

protected $fillable = [
  'name',
  'email',
];
Enter fullscreen mode Exit fullscreen mode

We'll also want to configure our mail driver so that we can preview our login emails. I like to use Mailtrap which is a free SMTP catcher (you can send emails to any address and they'll only show up in Mailtrap, not get delivered to the actual user), but you can use any you like.

If you don't want to set anything up, you can use the log mailer and the emails will show up in storage/logs/laravel.log as raw text.

Back in that same .env file from before:

# .env
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=redacted
MAIL_PASSWORD=redacted
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=hello@example.com
Enter fullscreen mode Exit fullscreen mode

We're now ready to get building!

Our approach

We talked about what the flow looks like from the user’s perspective at the beginning of this article, but how does this work from a technical perspective?

Well, given a user, we need to be able to send them a unique link that when they click it, logs them into their own account.

This tells us that we'll need to probably generate a unique token of some sort, associate it with the user trying to log in, build a route that looks at that token and determines if it's valid, and then logs the user in. We'll also want to only allow these tokens to be used once, and only be valid for a certain amount of time once it has been generated.

Since we need to keep track of whether or not the token has been used already, we're going to store them in the database. It will also be handy to keep track of which token belongs to which user, as well as if the token has been used or not, and if it has expired already.

Create a test user

We're only going to focus on the login flow in this article. It will be up to you to create a registration page, though it will follow all the same steps.

Because of this, we'll need a user in the database to test logging in. Let's create one using tinker:

$ php artisan tinker
> User::create(['name' => 'Jane Doe', 'email' => 'test@example.com'])
Enter fullscreen mode Exit fullscreen mode

The login route

We'll start out by creating a controller, AuthController, that we'll use to handle the login, verification, and logout functionality:

$ php artisan make:controller AuthController
Enter fullscreen mode Exit fullscreen mode

Now let's register the login routes in our app's routes/web.php file. Underneath the welcome route, let's define a route group that will protect our authentication routes using the guest middleware, stopping people already logged in from viewing them.

Inside that group, we'll create two routes. One for showing the login page, the other for handling the form's submission. We'll also give them names so that we can easily reference them later:

Route::group(['middleware' => ['guest']], function() {
  Route::get('login', [AuthController::class, 'showLogin'])->name('login.show');
  Route::post('login', [AuthController::class, 'login'])->name('login');
});
Enter fullscreen mode Exit fullscreen mode

Now the routes are registered but we need to create the actions that will respond to those routes. Let's create those methods in the controller we created app/Http/Controllers/AuthController.php.

For now we'll have our login page return a view located at auth.login (which we'll create next), and create a placeholder login method that we'll come back to once we build our form:

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class AuthController extends Controller
{
  public function showLogin()
  {
    return view('auth.login');
  }

  public function login(Request $request)
  {
    // TODO
  }
}
Enter fullscreen mode Exit fullscreen mode

We're going to use Laravel's templating system Blade and Tailwind CSS for our views.

Since the main focus of this article is on the backend logic, we're not going to go into detail on the styling. I don't want to spend time setting up a proper CSS config so we'll use this Tailwind CSS JIT CDN that we can drop into our layout that will handle pulling the right styles.

You may notice a flash of styles when you first load the page. This is because the styles don't exist until after the page loads. In a production environment you would not want this, but for the sake of the tutorial it is fine.

Let's start off by creating a general layout that we can use for all of our pages. This file will live in resources/views/layouts/app.blade.php:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{ $title }}</title>
</head>
<body>
  @yield('content')
  <script src="https://unpkg.com/tailwindcss-jit-cdn"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

There's a few things I'll point out here:

  • The page title will be set by a $title variable we will pass into the layout when we extend from it
  • The @yield('content') Blade directive - when we extend from this layout, we'll use a named section called “content” to place our page-specific content
  • The TailwindCSS JIT CDN script we are using to process our styles

Now that we have the layout, we can create the registration page in resources/views/auth/login.blade.php:

@extends('layouts.app', ['title' => 'Login'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      <h1 class="text-xl font-semibold">Login</h1>
      <form action="{{ route('login') }}" method="post" class="space-y-4">
        @csrf
        <div class="space-y-1">
          <label for="email" class="block">Email</label>
          <input type="email" name="email" id="email" class="block w-full border-gray-400 rounded-md px-4 py-2" />
          @error('email')
            <p class="text-sm text-red-600">{{ $message }}</p>
          @enderror
        </div>
        <button class="rounded-md px-4 py-2 bg-indigo-600 text-white">Login</button>
      </form>
    </div>
  </div>
@endsection
Enter fullscreen mode Exit fullscreen mode

There's a bit going on here, let's point some stuff out:

  • We start out by extending the layout we created earlier, and passing it a title of “Login” which will be our documents tab title
  • We declare a section called content (remember the @yield earlier?) and put our page content inside, which will get rendered into the layout
  • Some basic containers and styling are applied to center the form in the middle of the screen
  • The form's action points to a named route route('login') which, if we remember from the routes/web.php file, is the name we gave to the login POST request in our controller
  • We include the hidden CSRF field using the @csrf directive (read more here)
  • We conditionally show any validation errors provided by Laravel using the @error directive

If you load the page it should look like this:

Screenshot of Laravel web page with simple login box

Pretty basic, we just ask for the user’s email. If we submit the form right now you'll just see a blank white screen because our login method we defined earlier is empty. Let's implement the login method in our AuthController to send them a link to finish logging in.

The flow will look something like this: validate form data -> send login link -> show a message to the user back on the page telling them to check their email.

// app/Http/Controllers/AuthController.php
// near other use statements
use App\Models\User;

// inside class
public function login(Request $request)
{
  $data = $request->validate([
    'email' => ['required', 'email', 'exists:users,email'],
  ]);
  User::whereEmail($data['email'])->first()->sendLoginLink();
  session()->flash('success', true);
  return redirect()->back();
}
Enter fullscreen mode Exit fullscreen mode

There's a few things we're doing here:

  • Validating the form data - saying that the email is required, should be a valid email, and exist in our database
  • We find the user by the email provided, and call a function sendLoginLink which we will need to implement
  • We flash a value to the session indicating the request succeeded and then return the user back to the login page

There are a couple of incomplete tasks in the above steps so we'll need to implement those now.

We'll start with updating our login view to check for that success boolean, hiding our form, and showing the user a message if it's present. Back in resources/views/auth/login.blade.php:

@extends('layouts.app', ['title' => 'Login'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      @if(!session()->has('success'))
        <h1 class="text-xl font-semibold">Login</h1>
        <form action="{{ route('login') }}" method="post" class="space-y-4">
          @csrf
          <div class="space-y-1">
            <label for="email" class="block">Email</label>
            <input type="email" name="email" id="email" class="block w-full border-gray-400 rounded-md px-4 py-2" />
            @error('email')
              <p class="text-sm text-red-600">{{ $message }}</p>
            @enderror
          </div>
          <button class="rounded-md px-4 py-2 bg-indigo-600 text-white">Login</button>
        </form>
      @else
        <p>Please click the link sent to your email to finish logging in.</p>
      @endif
    </div>
  </div>
@endsection
Enter fullscreen mode Exit fullscreen mode

Here we simply wrapped the form in a conditional.

It's saying:

  • Did we just successfully submit a form?
    • No - show the registration form instead
    • Yes - let the user know that their account was created and to check their email for a link

Now if you were to submit that form again, you'll see an error saying we need to implement that sendLoginLink function on the User model. I like to store logic like that on the model itself so that we can reuse it in our application later.

Open up app/Models/User.php and create an empty method to fill its place:

public function sendLoginLink()
{
  // TODO
}
Enter fullscreen mode Exit fullscreen mode

Now submit the form again and ensure that you see your success message like below:

Screenshot of Laravel web page that reads

Of course you won't have received an email just yet, but now we can move on to that step.

Implementing the sendLoginLink function

Reflecting on the approach for tokens we discussed above, here's what we need to do now:

  1. Generate a unique token and attach it to the user
  2. Send the user an email with a link to a page that validates that token

We're going to keep these in a table called login_tokens. Let's create the model and the migration (-m):

$ php artisan make:model -m LoginToken
Enter fullscreen mode Exit fullscreen mode

For the migration we need:

  • A unique token for the URL we're generating
  • An association that ties it back to the requesting user
  • A date saying when the token expires
  • A flag that tells us whether or not the token has been consumed already. We're going to use a timestamp field for this since the absence of a value in this column will tell us if it's been used, and it being a timestamp also lets us know when it was consumed - double win!

Open up the migration that was generated and add the necessary columns:

Schema::create('login_tokens', function (Blueprint $table) {
  $table->id();
  $table->unsignedBigInteger('user_id');
  $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
  $table->string('token')->unique();
  $table->timestamp('consumed_at')->nullable();
  $table->timestamp('expires_at');
  $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

Make sure to run the migration afterwards:

$ php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Next update our new app/Models/LoginToken model to account for a few things:

  • Set our $guarded property to an empty array, meaning that we aren't restricting what columns can be filled
  • Create a $dates property that will cast our expires_at and consumed_at fields to Carbon\Carbon instances when we reference them in php code for convenience later on
  • Our user() method that lets us reference the user associated to the token
class LoginToken extends Model
{
  use HasFactory;

  protected $guarded = [];
  protected $dates = [
    'expires_at', 'consumed_at',
  ];

  public function user()
  {
    return $this->belongsTo(User::class);
  }
}
Enter fullscreen mode Exit fullscreen mode

It's also a good idea to place the inverse association on the User model:

// inside app/Models/User.php
public function loginTokens()
{
  return $this->hasMany(LoginToken::class);
}
Enter fullscreen mode Exit fullscreen mode

Now that we have the model set up we can do the first step of our sendLoginLink() function, which is creating the token.

Back inside app/Models/User.php we're going to create the token for the user using the new loginTokens() association we just created and give it a random string using the Str helper from Laravel and an expiry of 15m from now.

Because we set the expires_at and consumed_at as dates on the LoginToken model, we can simply pass a fluent date and it will be converted appropriately. We’ll also hash the token before we insert it into the database so that if this table were to be compromised no one could see the raw token values.

We’re using a hash that is reproducible so that we can look it up again later when needed:

use Illuminate\Support\Str;

public function sendLoginLink()
{
    $plaintext = Str::random(32);
    $token = $this->loginTokens()->create([
      'token' => hash('sha256', $plaintext),
      'expires_at' => now()->addMinutes(15),
    ]);
    // todo send email
}
Enter fullscreen mode Exit fullscreen mode

Now that we have a token, we can send the user an email that contains a link with the (plaintext) token in the URL that will validate their session. The token needs to be in the URL so we can look up what user it is for.

We don't just want to use the ID of the LoginToken because then a user could potentially go one by one to find a valid URL. We'll go over another way of protecting against this later on.

Start by creating the mailer class that will represent the email:

$ php artisan make:mail MagicLoginLink
Enter fullscreen mode Exit fullscreen mode

Open up the mailer generated at app/Mail/MagicLoginLink.php and enter the following:

<?php
namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;

class MagicLoginLink extends Mailable
{
  use Queueable, SerializesModels;

  public $plaintextToken;
  public $expiresAt;

  public function __construct($plaintextToken, $expiresAt)
  {
    $this->plaintextToken = $plaintextToken;
    $this->expiresAt = $expiresAt;
  }

  public function build()
  {
    return $this->subject(
      config('app.name') . ' Login Verification'
    )->markdown('emails.magic-login-link', [
      'url' => URL::temporarySignedRoute('verify-login', $this->expiresAt, [
        'token' => $this->plaintextToken,
      ]),
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Here's what's happening - the mailer will take in the plaintext token and the expiry date and store it in public properties. This will allow us to use it later in the build() method when it is being composed.

Inside the build() method we are setting the subject of the email, and telling it to look for a markdown formatted view inside resources/views/emails/magic-login-link.blade.php. Laravel provides some default styling for markdown emails that we will take advantage of in a moment.

We also pass a url variable to the view that is going to be the link the user clicks on.

That url property is a temporary signed URL. It takes in a named route, an expiration date (which we want to be our tokens expiration), and any parameters (in this case token being the unhashed random string we generated). A signed URL ensures that the URL has not been modified at all by hashing the URL with a secret only Laravel knows.

Even though we are going to add checks in our verify-login route to ensure our token is still valid (based on the expires_at and consumed_at properties), signing the URL gives us extra security at the framework level since no one will be able to brute force the verify-login route with random tokens to see if they can find one that logs them in.

Now we need to implement that markdown view at resources/views/emails/magic-login-link.blade.php. You might be wondering why the extension is .blade.php. This is because even though we are writing markdown in this file, we can use Blade directives inside to build reusable components we can use in our emails.

Laravel provides us with pre-styled components out of the box to get started right away. We're using mail::message which gives us a layout and a call-to-action via mail::button:

@component('mail::message')
  Hello, to finish logging in please click the link below
  @component('mail::button', ['url' => $url])
    Click to login
  @endcomponent
@endcomponent
Enter fullscreen mode Exit fullscreen mode

Now that we have the email content built out, we can finish the sendLoginLink() method by actually sending the email. We're going to use the Mail façade provided by Laravel to specify the users email that we're sending it to, and that the content of the email should be built from the MagicLoginLink class we just finished setting up.

We also use queue() instead of send() so that the email is sent in the background instead of during the current request. Make sure you have your queue driver set up appropriately or that you are using the sync driver (this is the default) if you want it to just happen immediately.

Back in app/Models/User.php:

use Illuminate\Support\Facades\Mail;
use App\Mail\MagicLoginLink;

public function sendLoginLink()
{
  $plaintext = Str::random(32);
  $token = $this->loginTokens()->create([
    'token' => hash('sha256', $plaintext),
    'expires_at' => now()->addMinutes(15),
  ]);
  Mail::to($this->email)->queue(new MagicLoginLink($plaintext, $token->expires_at));
}
Enter fullscreen mode Exit fullscreen mode

If you were to submit our login form, you would now see an email that looks like this:

Screenshot of a Laravel app that reads

The verification route

If you tried click the link, you probably received a 404 error. That's because in our email we sent the user a link to the verify-login named route, but we haven't created that yet!

Register the route in the route group inside routes/web.php:

Route::group(['middleware' => ['guest']], function() {
  Route::get('login', [AuthController::class, 'showLogin'])->name('login.show');
  Route::post('login', [AuthController::class, 'login'])->name('login');
  Route::get('verify-login/{token}', [AuthController::class, 'verifyLogin'])->name('verify-login');
});
Enter fullscreen mode Exit fullscreen mode

And we'll then create the implementation inside our AuthController class via a verifyLogin method:

public function verifyLogin(Request $request, $token)
{
  $token = \App\Models\LoginToken::whereToken(hash('sha256', $token))->firstOrFail();
  abort_unless($request->hasValidSignature() && $token->isValid(), 401);
  $token->consume();
  Auth::login($token->user);
  return redirect('/');
}
Enter fullscreen mode Exit fullscreen mode

Here we are doing the following:

  • Finding the token by hashing the plaintext value and comparing it with the hashed version in our database (throws 404 if not found - via firstOrFail())
  • Aborting the request with a 401 status code if the token is invalid, or the signed URL is invalid (you can get fancy here if you want to show a view or something letting the user know more information, but for the sake of this tutorial we will just kill the request)
  • Marking the token as used so it can't be used again
  • Logging in the user associated to the token
  • Redirecting them to the homepage

We call a couple of methods on the token that don't actually exist yet, so let's create them:

  • isValid() is going to be true if the token has not been consumed yet (consumed_at === null) and if it hasn't expired (expires_at <= now)
  • We'll extract the expired and consumed, checking to their own functions to make it more readable
  • consume() is going to set the consumed_at property to the current timestamp

I like to encapsulate this logic on the model directly so that it is easy to read and reuse. Open up app/Models/LoginToken.php:

public function isValid()
{
  return !$this->isExpired() && !$this->isConsumed();
}

public function isExpired()
{
  return $this->expires_at->isBefore(now());
}

public function isConsumed()
{
  return $this->consumed_at !== null;
}

public function consume()
{
  $this->consumed_at = now();
  $this->save();
}
Enter fullscreen mode Exit fullscreen mode

If you were to click that login link from your email now, you should be redirected to the / route!

You'll also notice that if you click the link again, you will be shown the error screen because it is now invalid.

Final touches

Now that our authentication flow is working, let's guard our root route to only be viewable by those who are logged in, and add a way to log out so we can do the flow again.

To start, edit the default root route in app/web.php to add the auth middleware:

Route::get('/', function () {
    return view('welcome');
})->middleware('auth');
Enter fullscreen mode Exit fullscreen mode

Let's also adjust that default welcome view to show a bit of info about our logged in user as well as provide a link to log out. Replace the contents of resources/views/welcome.blade.php with the following:

@extends('layouts.app', ['title' => 'Home'])
@section('content')
  <div class="h-screen bg-gray-50 flex items-center justify-center">
    <div class="w-full max-w-lg bg-white shadow-lg rounded-md p-8 space-y-4">
      <h1>Logged in as {{ Auth::user()->name }}</h1>
      <a href="{{ route('logout') }}" class="text-indigo-600 inline-block underline mt-4">Logout</a>
    </div>
  </div>
@endsection
Enter fullscreen mode Exit fullscreen mode

And finally the logout route that will forget our session and return us to the login screen. Open up routes/web.php again and add this route to the bottom of the file:

Route::get('logout', [AuthController::class, 'logout'])->name('logout');
Enter fullscreen mode Exit fullscreen mode

And finally we need to implement the logout action in our AuthController:

public function logout()
{
  Auth::logout();
  return redirect(route('login'));
}
Enter fullscreen mode Exit fullscreen mode

Now your home page should look like this and only be viewable by those who are logged in:

Screenshot of a Laravel web app that reads

Conclusion

That's a wrap! We covered a lot of ground but you'll notice the overall code we wrote is pretty low for a feature like this. I hope you learned a trick or two along the way.

Full source code can be viewed here.


LogRocket: Full visibility into your web apps

LogRocket Dashboard Free Trial Banner

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

Try it for free.

Top comments (0)