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.

image
Tinkerwell

Version 4 of Tinkerwell is available now. Get the most popular PHP scratchpad with all its new features and simplify your development workflow today.

Visit Tinkerwell
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
LoadForge logo

LoadForge

Easy, affordable load testing and stress tests for websites, APIs and databases.

LoadForge
Paragraph logo

Paragraph

Manage your Laravel app as if it was a CMS – edit any text on any page or in any email without touching Blade or language files.

Paragraph
Lucky Media logo

Lucky Media

Bespoke software solutions built for your business. We ♥ Laravel

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
DocuWriter.ai logo

DocuWriter.ai

Save hours of manually writing Code Documentation, Comments & DocBlocks, Test suites and Refactoring.

DocuWriter.ai
Rector logo

Rector

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

Rector

The latest

View all →
Generate Code Coverage in Laravel With PCOV image

Generate Code Coverage in Laravel With PCOV

Read article
Non-backed Enums in Database Queries and a withSchedule() bootstrap method in Laravel 11.1 image

Non-backed Enums in Database Queries and a withSchedule() bootstrap method in Laravel 11.1

Read article
Laravel Pint --bail Flag image

Laravel Pint --bail Flag

Read article
The Laravel Worldwide Meetup is Today image

The Laravel Worldwide Meetup is Today

Read article
Cache Routes with Cloudflare in Laravel image

Cache Routes with Cloudflare in Laravel

Read article
Learn how to manage timezones in your Laravel Apps image

Learn how to manage timezones in your Laravel Apps

Read article