Passwordless Authentication in Laravel: Magic Links with Signed URLs and Livewire
⚙️ Updated for Laravel 13. The code in this tutorial works without any changes. If you want to go a step further, Laravel 13 introduces native Passkey support (WebAuthn), a modern alternative to magic links that we cover at the end of the article.
Sometimes we don’t want users to have passwords at all. In some cases, we simply want to send a magic link to the user’s email address and let them click it to gain access.
In this tutorial, I’ll walk you through a process you can use to implement this yourself. The core of this workflow is creating a signed URL that lets us send a specific URL to the user’s email — and only that person should be able to use it.
First, we want to remove the password field from our migration, model, and model factory. Since it won’t be needed, we should make sure to remove it, as it is not a nullable column by default. This is a relatively straightforward process, so I won’t show code examples for that part. While we’re at it, we can also remove the password resets table, since there won’t be a password to reset.
The next thing to look at is routing. We can create our login route as a simple view route, since we’ll be using Livewire for this example. Let’s take a look at how to register 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 over the UI for this example, but at the end of the tutorial there’s a link to the GitHub repository. Let’s look at the Livewire component we’ll 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 so you can sign 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’ll want to use. The email is used to capture the form input. The status property lets us avoid relying 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. The submit method is the main method for this component, and it’s a naming convention I use when dealing with form components. It makes a lot of sense to me, but feel free to choose a naming convention that works for you. We use Laravel’s 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 entered email to the action and set a status alert informing the user that the email is being sent.
Now let’s review 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 could configure it to be queued if we want, but when it comes to an action that requires fast processing, it’s better to queue it if we’re building an API. We have a mailable class called LoginLink to which we pass the URL we want to use. Our URL is created by passing the name of a route we want to generate and the parameters we want to include as part of the signature.
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 differ much from a standard mailable. We pass a string for the URL, then pass it to a markdown view in the content method.
<x-mail::message>
# Login Link
Use the link below to sign in to {{ config('app.name') }}.
<x-mail::button :url="$url">
Sign In
</x-mail::button>
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
The user will receive this email and click the link, taking them to the signed URL. Let’s register this route and see what it looks like.
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 make sure to add the signed middleware. Now let’s 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 make sure the URL has a valid signature — if it doesn’t, we throw an unauthorized response. Once we know the signature is valid, we can look up the user and authenticate them. Finally, we return a redirect to the dashboard.
Our user is now successfully logged in and the flow is complete. However, we also need to handle the registration route. Let’s add it below. Again, it 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 as we did with the login flow.
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' => 'An error occurred, please try again.',
],
);
}
$action->handle(
email: $this->email,
);
$this->status = 'We\'ve sent you an email so you can sign 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 user’s name and email address and have a status property instead of relying on the request session again. We again use a rules method to return the validation rules for this request. Back in the submit method, 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 any reason, we throw a validation exception on the email field. Then we use the SendLoginLink action from 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 technically that’s what we’re doing again. We create a user, then sign them in.
This is one of many approaches you can take to implement passwordless authentication, but it’s an approach that works.
Bonus 2026: Passkeys with Laravel 13
Starting with Laravel 13 (March 2026), the framework includes first-class support for passkeys based on WebAuthn. Passkeys are the natural evolution beyond magic links: they don’t require email, they’re phishing-resistant, and modern devices (iPhone, Android, macOS, Windows) handle them natively via Face ID / Touch ID / Windows Hello.
php artisan install:api # if you haven't already
# The AI SDK / Passkey scaffolding are included in Laravel 13
The core concept:
- The user registers once with their email and a passkey (the browser saves a cryptographic key on their device).
- On subsequent logins, they simply approve the biometric prompt — no typing, no waiting for an email.
If you’re building a new product in 2026, it’s well worth considering passkeys as the primary flow and keeping magic links as a recovery fallback.
If you’d like to go deeper with Laravel 13, don’t miss my guides on How to Install Laravel 13 and How to Build a REST API with Laravel 13.