Passwordless Authentication in Laravel

Published on by

Passwordless Authentication in Laravel image

Sometimes we don't want users to have passwords. Sometimes we want to send a magic link to a user's email address and have them click to gain access.

In this tutorial, I will walk through a process you can use to implement this yourself. The main focus of this workflow is to create a signed URL that will allow us to send a specific URL to the users' email address, and only that person should be able to access this URL.

We first want to remove the password field from our migration, model, and model factory. As this won't be needed, we want to ensure we remove it, as it is not a nullable column by default. This is a relatively simple process to achieve, so I won't show any code examples for this part. While we are at it, we can remove the password resets table, as we will not have a password to reset.

Routing should be the next thing we look at. We can create our login route as a simple view route, as we will use Livewire for this example. Let's have a look at registering this route:

Route::middleware(['guest'])->group(static function (): void {
Route::view('login', 'app.auth.login')->name('login');
});

We want to wrap this in the guest middleware to force a redirect if the user is already logged in. I won't go through the UI for this example, but at the end of the tutorial, there is a link to the repo on GitHub. Let's walk through the Livewire component that we will use for the login form.

final class LoginForm extends Component
{
public string $email = '';
 
public string $status = '';
 
public function submit(SendLoginLink $action): void
{
$this->validate();
 
$action->handle(
email: $this->email,
);
 
$this->status = 'An email has been sent for you to log in.';
}
 
public function rules(): array
{
return [
'email' => [
'required',
'email',
Rule::exists(
table: 'users',
column: 'email',
),
]
];
}
 
public function render(): View
{
return view('livewire.auth.login-form');
}
}

Our component has two properties we will want to use. The email is used to capture the form input. Then the status is, so we don't need to rely on the request session. We have a method that returns the validation rules. This is my preferred approach for validation rules in a Livewire component. Our submit method is the primary method for this component, and it is a naming convention that I use when dealing with form components. This makes a lot of sense to me, but feel free to choose a naming method that works for you. We use Laravels container to inject an action class into this method to share the logic for creating and sending a signed URL. All we need to do here is pass the email entered through to the action and set a status alerting the user that the email is being sent.

Let's now walk through the action we want to use.

final class SendLoginLink
{
public function handle(string $email): void
{
Mail::to(
users: $email,
)->send(
mailable: new LoginLink(
url: URL::temporarySignedRoute(
name: 'login:store',
parameters: [
'email' => $email,
],
expiration: 3600,
),
)
);
}
}

This action only needs to send an email. We can configure this to be queued if we want to - but when dealing with an action requiring quick processing, it is better to queue it if we are building an API. We have a mailable class called LoginLink that we pass through the URL we want to use. Our URL is created by passing in the name of a route we want to generate a route for and passing the parameters that you want to use as part of the signing.

final class LoginLink extends Mailable
{
use Queueable, SerializesModels;
 
public function __construct(
public readonly string $url,
) {}
 
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your Magic Link is here!',
);
}
 
public function content(): Content
{
return new Content(
markdown: 'emails.auth.login-link',
with: [
'url' => $this->url,
],
);
}
 
public function attachments(): array
{
return [];
}
}

Our mailable class is relatively straightforward and doesn't defer much from a standard mailable. We pass in a string for the URL. Then, we want to pass this through to a markdown view in the content.

<x-mail::message>
# Login Link
 
Use the link below to log into the {{ config('app.name') }} application.
 
<x-mail::button :url="$url">
Login
</x-mail::button>
 
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

The user will receive this email and click on the link, taking them through to the signed URL. Let's register this route and see how it looks.

Route::middleware(['guest'])->group(static function (): void {
Route::view('login', 'app.auth.login')->name('login');
Route::get(
'login/{email}',
LoginController::class,
)->middleware('signed')->name('login:store');
});

We want to use a controller for this route and ensure we add the signed middleware. Now let us look at the controller to see how we handle signed URLs.

final class LoginController
{
public function __invoke(Request $request, string $email): RedirectResponse
{
if (! $request->hasValidSignature()) {
abort(Response::HTTP_UNAUTHORIZED);
}
 
/**
* @var User $user
*/
$user = User::query()->where('email', $email)->firstOrFail();
 
Auth::login($user);
 
return new RedirectResponse(
url: route('dashboard:show'),
);
}
}

Our first step is to ensure that the URL has a valid signature, and if it doesn't, we want to throw an unauthorized response. Once we know the signature is valid, we can query for the user passed through and authenticate them. Finally, we return a redirect to the dashboard.

Our user is now logged in successfully, and our journey is complete. However, we need to look at the registration route too. Let's add this route next. Again this will be a view route.

Route::middleware(['guest'])->group(static function (): void {
Route::view('login', 'app.auth.login')->name('login');
Route::get(
'login/{email}',
LoginController::class,
)->middleware('signed')->name('login:store');
 
Route::view('register', 'app.auth.register')->name('register');
});

Again, we use a livewire component for the registration form - just like we did with the login process.

final class RegisterForm extends Component
{
public string $name = '';
 
public string $email = '';
 
public string $status = '';
 
public function submit(CreateNewUser $user, SendLoginLink $action): void
{
$this->validate();
 
$user = $user->handle(
name: $this->name,
email: $this->email,
);
 
if (! $user) {
throw ValidationException::withMessages(
messages: [
'email' => 'Something went wrong, please try again later.',
],
);
}
 
$action->handle(
email: $this->email,
);
 
$this->status = 'An email has been sent for you to log in.';
}
 
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:2',
'max:55',
],
'email' => [
'required',
'email',
]
];
}
 
public function render(): View
{
return view('livewire.auth.register-form');
}
}

We capture the users' name, email address, and have a status property instead of using the request session again. Again we use a rules method to return the validation rules for this request. We come back to the submit method, where this time, we want to inject two actions.

CreateNewUser is the action we use to create and return a new user based on the information provided. If this fails for some reason, we throw a validation exception on the email. Then we use the SendLoginLink action we used on the login form to minimize code duplication.

final class CreateNewUser
{
public function handle(string $name, string $email): Builder|Model
{
return User::query()->create([
'name' => $name,
'email' => $email,
]);
}
}

We could rename the login store route, but it is technically what we are doing again. We create a user. Then we want to log the user in.

This is one of many approaches you can take to implement passwordless authentication, but this is one approach that does work. You can find the GitHub Repo here, and if you think this could be improved, feel free to drop a PR!

Steve McDougall photo

Technical writer at Laravel News, Developer Advocate at Treblle. API specialist, veteran PHP/Laravel engineer. YouTube livestreamer.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
A Resize Plugin for Alpine.js image

A Resize Plugin for Alpine.js

Read article
How to Migrate MySQL from DBngin to Laravel Herd image

How to Migrate MySQL from DBngin to Laravel Herd

Read article
Learn to master Query Scopes in Laravel image

Learn to master Query Scopes in Laravel

Read article
How to Redirect Uppercase URLs to Lowercase with Laravel Middleware image

How to Redirect Uppercase URLs to Lowercase with Laravel Middleware

Read article
PHP 8.4 Alpha 1 is now out! image

PHP 8.4 Alpha 1 is now out!

Read article
Generics Added to Eloquent Builder in Laravel 11.15 image

Generics Added to Eloquent Builder in Laravel 11.15

Read article