DEV Community

Cover image for Passwordless Laravel Auth Is Easy to Demo and Harder to Run Well
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

Passwordless Laravel Auth Is Easy to Demo and Harder to Run Well

Passwordless auth sounds like a simplification until you try to run it in a real Laravel product. The UI gets simpler. The security model does not. You remove the password field, but you still have to prove identity, prevent replay, handle hostile email infrastructure, preserve decent UX across devices, and give support a way to debug failures without turning into a manual override team.

That is why most magic-link examples are fine for prototypes and incomplete for production. They focus on generating a signed URL and calling Auth::login() when it is opened. That is the easy part. The hard part is everything around the click.

My recommendation is blunt: do not implement passwordless auth in Laravel as a “special login link” feature. Implement it as a short-lived authentication workflow with explicit state, single-use consumption, step-up hooks, and support visibility. If you skip those layers, you are not shipping passwordless auth. You are shipping a bearer token in an email and hoping the rest works out.

The first threat is often your user's email security stack

A lot of magic-link writeups quietly assume the first request to the link comes from the human who owns the inbox. In production, that assumption breaks fast.

Enterprise email systems, secure mail gateways, antivirus products, browser preview services, and mobile mail clients often prefetch links. Some do it to scan for malware. Some do it to build previews. Some follow redirects. If your Laravel route performs the login on the first GET, one of those automated actors can consume the credential before the user even sees the email.

That creates a failure mode that feels bizarre to users. They click the email one minute later and see “link expired” or “token already used.” From their perspective, your auth is broken. From your perspective, it worked exactly once, just not for the person you intended.

This is why magic-link login should almost never be consume-on-GET in a serious app.

Use email links to resume a flow, not to finish it

A better pattern is simple:

  1. User requests passwordless sign-in.
  2. You create a login attempt record in the database.
  3. You email a signed URL that identifies that attempt.
  4. The GET request validates the link and renders a confirmation page.
  5. A deliberate POST consumes the attempt and creates the session.

That extra round-trip is not busywork. It is what separates “clickable email token” from “scanner-resistant auth flow.” Automated scanners are very good at issuing GET requests. They are much worse at completing a CSRF-protected browser form in the same session context.

A minimal route shape in Laravel can look like this:

Route::middleware('guest')->group(function () {
    Route::post('/login/passwordless', RequestMagicLinkController::class)
        ->middleware('throttle:5,1')
        ->name('passwordless.request');

    Route::get('/login/passwordless/{attempt}', ShowMagicLinkController::class)
        ->middleware('signed')
        ->name('passwordless.show');

    Route::post('/login/passwordless/{attempt}', ConsumeMagicLinkController::class)
        ->middleware(['signed', 'throttle:6,1'])
        ->name('passwordless.consume');
});
Enter fullscreen mode Exit fullscreen mode

The important design choice is not the route naming. It is the separation of concerns.

  • The email link proves the user reached the mailbox.
  • The confirmation POST proves a browser session intentionally completed the flow.
  • Your application decides whether that is enough for full access or only enough for provisional access.

Signed URLs are useful, but not sufficient

Laravel's signed URL support is absolutely worth using here. It protects query integrity and expiration. But it does not solve the full problem.

A signed URL does not make the underlying login attempt single-use. It does not tell you whether a link was consumed by a scanner. It does not stop a forwarded email from being reused inside its lifetime. It does not decide whether a new device should be trusted.

That distinction matters because teams often mistake “cryptographically signed” for “production-ready.” Those are different claims.

The core object is not the link. It is the login attempt

If you want passwordless auth to survive production, you need a first-class persistence model for the flow. The link should point to a login attempt record, not carry the whole security story inside the URL.

A reasonable schema usually includes:

  • id
  • user_id
  • token_hash
  • expires_at
  • consumed_at
  • requested_ip
  • requested_user_agent
  • requested_at
  • redirect_to
  • device_label
  • risk_flags or equivalent JSON metadata

The token itself should be random, short-lived, and stored only as a hash. Treat it the same way you treat reset tokens: the raw secret exists only long enough to email it.

Hash the token and keep the URL boring

You do not need a clever token format. You need an unpredictable one.

use Illuminate\Support\Str;

$plainToken = Str::random(64);

$attempt = MagicLoginAttempt::create([
    'user_id' => $user->id,
    'token_hash' => hash('sha256', $plainToken),
    'expires_at' => now()->addMinutes(15),
    'requested_ip' => request()->ip(),
    'requested_user_agent' => substr((string) request()->userAgent(), 0, 1000),
    'redirect_to' => '/dashboard',
]);

$url = URL::temporarySignedRoute(
    'passwordless.show',
    $attempt->expires_at,
    [
        'attempt' => $attempt->id,
        'token' => $plainToken,
    ]
);
Enter fullscreen mode Exit fullscreen mode

That is enough. Resist the urge to encode user data, device hints, or trust semantics into the token itself. Put state in the database where you can revoke it, inspect it, and reason about it.

One active attempt or many?

This is an architectural choice worth making explicitly.

If a user requests five links in ten minutes, what should happen?

The easy answer is to allow all of them until they expire. The operationally cleaner answer is usually to invalidate older pending attempts when a newer one is issued, especially for low-friction consumer flows. That reduces confusion and gives support a clearer story: “only the latest email works.”

For B2B apps, I prefer one of these two policies:

  • Single active attempt per user for standard sign-in.
  • Scoped active attempts when you want parallel flows, such as one for web login and one for privileged action confirmation.

What I would avoid is laissez-faire token issuance with overlapping validity windows. That produces the exact support tickets you do not want.

Replay protection is the part that separates demos from systems

A magic link is a bearer credential. That means whoever presents it first within the allowed window may gain access unless you design the consumption step carefully.

The minimum bar is straightforward:

  • the token must be single-use
  • token consumption must be atomic
  • successful login must rotate the session
  • second use must fail cleanly, not race unpredictably
  • old attempts should not remain ambiguously “sort of valid”

Consume with an atomic state transition

The most common bug in homemade passwordless auth is loading a valid attempt, checking it, and then updating it later in a way that allows two near-simultaneous requests to succeed.

The right mental model is not “validate then login.” It is “win the one-time state transition, then login.”

public function __invoke(Request $request, string $attemptId)
{
    $attempt = MagicLoginAttempt::query()
        ->whereKey($attemptId)
        ->where('expires_at', '>', now())
        ->firstOrFail();

    abort_unless(
        hash_equals($attempt->token_hash, hash('sha256', (string) $request->string('token'))),
        403
    );

    $updated = MagicLoginAttempt::query()
        ->whereKey($attempt->id)
        ->whereNull('consumed_at')
        ->where('expires_at', '>', now())
        ->update([
            'consumed_at' => now(),
            'consumed_ip' => $request->ip(),
            'consumed_user_agent' => substr((string) $request->userAgent(), 0, 1000),
        ]);

    abort_if($updated !== 1, 409, 'This sign-in link is no longer valid.');

    Auth::loginUsingId($attempt->user_id, remember: false);
    $request->session()->regenerate();

    return app(PostMagicLoginRedirector::class)->handle($request, $attempt);
}
Enter fullscreen mode Exit fullscreen mode

That update call is doing the real work. Only one request gets to transition the attempt from pending to consumed.

Do not overfit to IP and user agent

A lot of teams see replay risk and immediately try to bind the flow to IP address or browser fingerprint. That sounds strong until real users hit it from mobile networks, privacy-preserving browsers, VPNs, or email apps that hand off to a different browser instance.

My advice is to treat those signals as risk indicators, not absolute truth.

Good uses of those signals:

  • require extra confirmation if the consume request is materially different from the request that created the attempt
  • downgrade trust for the resulting session
  • add audit metadata
  • trigger step-up auth for sensitive accounts

Bad uses:

  • hard-block normal device transitions
  • permanently deny login because the IP changed between request and click
  • pretend browser fingerprints are stable enough to be identity proof

Passwordless auth gets worse, not better, when you replace passwords with brittle environmental checks.

Expiration should reflect attack window, not developer convenience

Fifteen minutes is a common default because it feels reasonable, and for many apps it is. But do not pick a TTL because it appears in tutorials. Pick it because it matches the risk of the action.

A low-privilege product login can often tolerate a short-lived email token. A billing confirmation or admin access flow should usually have a narrower window and stronger follow-up checks.

The right question is not “what expiry do other apps use?” It is “how much damage can a leaked email link do before it expires?”

Device trust and step-up auth are where the architecture gets real

A lot of teams imagine passwordless as a replacement for the entire auth stack. That is the wrong framing for any app with meaningful account value.

In practice, passwordless auth is usually your primary login factor, not your entire authorization model. Once the user proves inbox access, you still need a policy for new devices, risky sessions, privileged actions, and recovery.

This is where many Laravel apps should stop improvising and let Fortify handle the second-factor layer.

Magic link first, Fortify second

If you already use Laravel Fortify, the clean approach is to keep concerns separate:

  • passwordless flow establishes baseline identity
  • Fortify handles TOTP or other second-factor challenge when required
  • trusted-device state influences whether challenge can be skipped
  • authorization gates for sensitive actions still check current assurance level

That design matters because it lets you avoid a common anti-pattern: stuffing every security decision into the magic-link controller.

The magic-link flow should answer one question: did this user complete a valid email-based sign-in attempt?

Fortify and your trust policy should answer the next question: is this session strong enough for the thing they are trying to do?

Provisional sessions are underrated

A clean way to integrate the handoff is to create a real authenticated session after successful token consumption, but mark it as provisional until required step-up checks pass.

For example, after Auth::loginUsingId(), you can set session metadata like:

  • auth_method=passwordless_email
  • auth_assurance=low
  • step_up_required=true
  • trusted_device=false

Then middleware or your redirect layer can decide whether the user goes straight to the dashboard, to the Fortify challenge, or to a limited-access screen.

That gives you a lot more control than treating auth as a binary “logged in or not” state.

Trusted device should be revocable server state

The lazy version of trusted device is a permanent cookie that says “do not ask again.” That is convenient and weak.

A better version is:

  • a random device token stored as a hashed record server-side
  • an expires_at
  • a label the user can understand, like “MacBook Pro, Chrome”
  • a last_seen_at
  • a way to revoke all or one device from account settings

That lets you preserve smooth repeat sign-in without making trust irreversible.

The policy should also be narrower than most teams first imagine. Trusted device should usually reduce friction for routine access. It should not silently authorize sensitive actions forever.

A practical policy might look like this:

  • ordinary product access: allow with passwordless login
  • new device on admin account: require Fortify challenge
  • billing change or API key creation: require recent step-up regardless of trusted device
  • team ownership transfer: require fresh challenge, always

That is the difference between “passwordless login” and “passwordless security theater.”

Support and observability determine whether the system survives launch

The first time passwordless auth fails, support will learn more about your design than your developers did.

Users do not file tickets that say “I suspect your token consumption semantics are vulnerable to prefetch races.” They say things like:

  • “the link did not work”
  • “I got logged out and the new email also failed”
  • “it says already used”
  • “I’m on a new laptop and now I’m stuck”

If your team cannot reconstruct the flow quickly, they will start inventing manual fixes. That is where insecure operational habits are born.

Log events across the lifecycle

At minimum, emit structured events for:

  • login attempt created
  • email dispatch attempted
  • email dispatch succeeded or failed
  • landing route viewed
  • consume requested
  • consume rejected with reason
  • session created
  • Fortify challenge initiated
  • challenge completed or failed
  • trusted device granted, refreshed, revoked

The events do not need to be fancy. They need to be correlated.

MagicLinkRequested::dispatch($attempt->id, $user->id);
MagicLinkEmailSent::dispatch($attempt->id, $user->id);
MagicLinkViewed::dispatch($attempt->id, request()->ip());
MagicLinkConsumeRejected::dispatch($attempt->id, 'expired');
MagicLinkConsumed::dispatch($attempt->id, $user->id);
StepUpChallengeRequired::dispatch($user->id, session()->getId());
TrustedDeviceGranted::dispatch($user->id, $device->id);
Enter fullscreen mode Exit fullscreen mode

What matters is that a support engineer or developer can inspect a timeline and answer basic questions without guessing.

Build for common failure stories, not just malicious ones

The most frequent problems are usually operational, not adversarial:

  • the user clicked the oldest of several emails
  • the corporate mail gateway prefetched the link
  • the user opened the email on mobile but expected the desktop browser to be logged in
  • the device changed and step-up auth was required unexpectedly
  • the email delivery lag exceeded the token TTL

Your system should be opinionated about these cases.

For example, if you detect that an already-consumed attempt was first viewed by a mail-security user agent before the real browser arrived, your UI can say something useful instead of a generic failure message: “Your email security scanner may have opened this sign-in link. Request a new one.”

That is not just better UX. It reduces pointless support volume.

Recovery policy is part of the auth design

Passwordless auth fails hardest when inbox access is degraded or lost. If the user's email is unavailable and their trusted device is gone, what happens next?

You need that answer before launch.

Good recovery design usually includes:

  • a clear operator policy for what support may and may not do
  • audited device revocation and session revocation
  • strong identity proof requirements before account recovery
  • explicit separation between “resend login flow” and “override security controls”

What I would avoid is first-line support having the power to casually disable two-factor settings or impersonate users. That turns your help desk into the weakest part of the auth stack.

What I would actually ship in a Laravel app

If I were replacing passwords in a real Laravel product today, I would keep the design deliberately boring.

The implementation would have these properties:

  • email links resume a flow on GET; they do not log users in directly
  • login attempts are stored server-side with hashed tokens and explicit lifecycle fields
  • only the latest standard sign-in attempt remains valid unless there is a clear reason otherwise
  • token consumption happens on POST with CSRF protection and atomic single-use update
  • successful consumption regenerates the session immediately
  • the resulting session carries an assurance level, not just a yes/no login state
  • Fortify handles step-up auth instead of homemade challenge logic scattered across controllers
  • trusted-device records are revocable, expiring, and backed by server state
  • sensitive actions require recent assurance, not just any authenticated session
  • support can inspect the full event trail without ever seeing raw tokens

That is not the shortest path to “passwordless.” It is the shortest path I would trust.

Magic links are valuable. They remove password reset churn, reduce credential-stuffing exposure, and often improve the first-login experience. Those are real wins. But they are only wins if the surrounding system is tighter than the password flow you replaced.

The decision rule is simple: if your Laravel passwordless auth can be consumed by a scanner, replayed by a second request, trusted forever on the wrong device, or debugged only by guesswork, you have not finished the design. The magic link is not the hard part. The operational model is.


Read the full post on QCode: https://qcode.in/passwordless-laravel-auth-magic-links-are-not-the-hard-part/

Top comments (0)