The two flows

Magic links

The user submits their email, receives a signed URL, clicks it, and is signed in. No form to fill in on the way back.


The flow

StepRouteWhat happens
1GET /auth/magic-linkShow the email form.
2POST /auth/magic-linkResolve the user, generate a token, email the link, show the "check your inbox" screen.
3GET /auth/magic-link/{token}Validate the signature and token, log the user in, redirect.

Step 3 is protected by the passwordless.signed middleware, which rejects a URL whose signature does not verify or whose expiry has passed before the controller is ever reached.


Sending one

From your own code, at any point — a controller, a job, an invite flow:

use Torqie\LaravelPasswordless\Facades\LaravelPasswordless;

$url = LaravelPasswordless::for($user)->sendMagicLink();

This generates the token, notifies the user, fires MagicLinkSent, and returns the fully-signed URL. You usually do not need the return value — it is there for tests and for cases where you want to deliver the link over something other than email.

Or just link a user at the built-in form and let the package handle it:

<a href="{{ route('passwordless.magic-link.request') }}">Sign in with a magic link</a>

The token

64 random characters, hashed with sha256 and stored. The URL is additionally signed with Laravel's own signed-URL machinery, so it carries two independent guarantees:

  • The signature proves the URL was not tampered with and has not passed its expiry.
  • The token row proves it has not already been used.

Consuming a link marks used_at rather than deleting the row. See why.

Email scanners click links

Corporate mail security products and link prefetchers fetch URLs in incoming mail before the recipient sees them. A magic link is single-use, so a scanner that follows it burns it — the human then arrives at a dead link.

This is not something the package can prevent, and it is exactly why used_at is retained: a row with a used_at a second after created_at and no corresponding UserAuthenticatedPasswordlessly event is a scanner, not your user. If you see this pattern, login codes are the flow that is immune to it.


Expiry

Controlled by ttl, in minutes, shared with login codes:

// config/passwordless.php
'ttl' => 15,
PASSWORDLESS_TTL=15

The value is also passed to the email template as $expiresMins, so the message can tell the user how long they have.


Customising the email

The notification renders laravel-passwordless::emails.magic-link. Point the config at your own view to replace it wholesale:

'views' => [
    'magic_link_email' => 'emails.auth.magic-link',
],

Your view receives:

VariableTypeDescription
$urlstringThe fully-signed magic link URL
$expiresMinsintTTL in minutes

Or publish the built-in templates and edit them in place:

php artisan vendor:publish --tag="laravel-passwordless-views"

See Blade views for the full list of overridable views.


Where the user lands

On success, redirects.after_login. On an invalid or expired token, redirects.invalid_token.

'redirects' => [
    'after_login'   => '/dashboard',
    'invalid_token' => '/login',
],

Reusing the middleware

passwordless.signed is registered as a route middleware alias and is not specific to this package's routes. If you have your own token-bearing route that should carry the same signature and expiry guarantees:

Route::get('/invitations/{token}', AcceptInvitation::class)
    ->middleware('passwordless.signed');
Previous
How it works