ahmedfaizan's avatar

Laravel Login link issue

This is my first time posting a question on Laracast. I am stuck at an extremely annoying issue. So I created a login option by sending a link via email. It works fine in all but Outlook or Hotmail. It adds emea01.safelinks.protection.outlook.com at the start. Email verification email works fine. I dont know what is wrong with my code :

AuthenticatedSessionController:

route:

    Route::post('/passwordless/send-code', [AuthenticatedSessionController::class, 'sendCode'])->name('passwordless.sendCode');
   
        // Add this GET route for magic login links
    Route::get('/passwordless/login/{user}', [AuthenticatedSessionController::class, 'passwordlessLogin'])
        ->middleware(['signed', 'throttle:6,1'])
        ->name('passwordless.login.get');
1 like
15 replies
LaryAI's avatar
Level 58

Certainly! This is a common issue when sending links via email to Outlook/Hotmail users: Microsoft’s "Safe Links" feature rewrites URLs in emails for security, which can sometimes break signed URLs (like Laravel’s temporarySignedRoute) by altering or encoding them, causing signature validation to fail.

Why does this happen?

  • Outlook/Hotmail wraps links with their own domain (safelinks.protection.outlook.com) and may encode or modify query parameters.
  • Laravel’s signed URLs are sensitive to any change in the URL (even encoding differences), so the signature check fails.

Solution

1. Use POST Instead of GET for Login Links (Recommended)

Safe Links only rewrites GET links. If you send a link to a page with a form (with a hidden token), and the user submits the form (POST), the signature is not broken. However, this is a UX change.

Example:

  • The email link goes to a simple page with a form:
    Route::get('/passwordless/verify/{user}', [AuthenticatedSessionController::class, 'showVerifyForm'])
        ->middleware(['signed', 'throttle:6,1'])
        ->name('passwordless.verify.get');
    
    Route::post('/passwordless/login/{user}', [AuthenticatedSessionController::class, 'passwordlessLogin'])
        ->name('passwordless.login.post');
    
  • The GET route just shows a form with the token as a hidden field.
  • The POST route actually logs the user in.

This approach is the most robust against Safe Links.


2. Shorten the Signed URL

Safe Links is less likely to break short URLs. You can use a URL shortener (like Bitly or TinyURL) in your notification, but this adds a dependency and may not be ideal for security.


3. Use Route Parameters Instead of Query Parameters

Safe Links sometimes breaks query parameters. You can try to move all parameters into the route itself:

Change your route:

Route::get('/passwordless/login/{user}/{token}/{urlid?}', [AuthenticatedSessionController::class, 'passwordlessLogin'])
    ->middleware(['signed', 'throttle:6,1'])
    ->name('passwordless.login.get');

Change your notification:

$signedUrl = URL::temporarySignedRoute(
    'passwordless.login.get',
    now()->addMinutes(15),
    [
        'user' => $notifiable->id,
        'token' => $this->token,
        'urlid' => $this->customId,
    ]
);

Update your controller to get $token and $urlid from route parameters.


4. Add a Fallback for Signature Failure

If signature validation fails, show a message to the user to copy-paste the link into their browser, or provide a "Resend Link" option.


5. Check for Double Encoding

Sometimes Safe Links double-encodes the URL. You can try decoding the token parameter in your controller:

$token = $request->token ?? $request->input('token');
$token = urldecode($token);

Summary Table

Solution Pros Cons
POST instead of GET Most robust, works everywhere Adds a step (user must submit a form)
Shorten the signed URL Sometimes works Adds dependency, less secure
Use route parameters Often works May not work if Safe Links still encodes
Fallback for signature failure User-friendly Not a real fix, just a workaround
Decode token in controller Quick fix, sometimes works Not always effective

Recommended Fix

Switch to a POST-based login flow for maximum reliability.

Example:

Notification:

$signedUrl = URL::temporarySignedRoute(
    'passwordless.verify.get',
    now()->addMinutes(15),
    [
        'user' => $notifiable->id,
        'token' => $this->token,
        'urlid' => $this->customId,
    ]
);

GET Route:

Route::get('/passwordless/verify/{user}/{token}/{urlid?}', [AuthenticatedSessionController::class, 'showVerifyForm'])
    ->middleware(['signed', 'throttle:6,1'])
    ->name('passwordless.verify.get');

showVerifyForm:

public function showVerifyForm(Request $request, User $user, $token, $urlid = null)
{
    return view('auth.magic-login', [
        'user' => $user,
        'token' => $token,
        'urlid' => $urlid,
    ]);
}

Blade view:

<form method="POST" action="{{ route('passwordless.login.post', ['user' => $user->id]) }}">
    @csrf
    <input type="hidden" name="token" value="{{ $token }}">
    @if($urlid)
        <input type="hidden" name="urlid" value="{{ $urlid }}">
    @endif
    <button type="submit">Login</button>
</form>

POST Route:

Route::post('/passwordless/login/{user}', [AuthenticatedSessionController::class, 'passwordlessLogin'])
    ->name('passwordless.login.post');

Controller:

public function passwordlessLogin(Request $request, User $user)
{
    $token = $request->input('token');
    $customId = $request->input('urlid');
    // ... rest of your logic
}

Conclusion

Outlook/Hotmail Safe Links will always be a problem for signed URLs in query strings. The best solution is to use a POST-based login flow, as described above.

If you need help refactoring your code to this approach, let me know!

Glukinho's avatar

It seems this is not your issue, it is Outlook/Hotmail/Office365 that adds "protection" to all links in email messages. When a user clicks this link, he is redirected to a site which performs security check over an actual link, and if it is safe, he is redirected to an actual link. You can't avoid this behavior in your code, it is specific to Microsoft email services.

2 likes
ahmedfaizan's avatar

Can I ask why this is not the case when you verify the email?

1 like
Glukinho's avatar

@ahmedfaizan can you show an example? In what case you see normal link, and in what case you see a link with something attached to it. And please show actual links in both cases.

1 like
Glukinho's avatar

By the way, this behaviour doesn't affect your app much, your links are still functional, just Hotmail/Microsoft users reach them 5 seconds longer.

Glukinho's avatar

@ahmedfaizan so, all your users get normal links, but only Microsoft/Hotmail users get "protected" links, right? I don't see how you can fix it from your side.

ahmedfaizan's avatar

Thank you @Glukinho. I was wondering maybe i am doing something wrong for outlook to change my link.

ahmedfaizan's avatar

The html output from the resend delivery:

ahmedfaizan's avatar
ahmedfaizan
OP
Best Answer
Level 4

I finally figured it out. so the outlook was sending a head request, this invalidated the link. To Handle HEAD requests from link scanners (e.g., Outlook Safe Links) I added this to passwordlessLogin:

if ($request->isMethod('head')) {
        return response('', 200);
    }

1 like
Glukinho's avatar

@ahmedfaizan good! I didn't realize you're asking about protection invalidating your links, thought your question was "why my links are prepended sometimes?"

Please or to participate in this conversation.