Zebra Codes

Impersonation in Laravel 10 with Sanctum

16th of August, 2023

Impersonation is a useful tool: it allows an administrator to view the website as if they were logged in as another user, but without having to know their password. The Laravel documentation implies that you can do this using Auth::loginUsingId(), but you will find that this doesn’t work.

Authentication Flow

Laravel’s authentication system contains two main parts:

  • Providers supply a database of user accounts.
  • Guards authenticate the user with each HTTP request.

It is the guards that we are interested in. The default guard is Illuminate\Auth\SessionGuard, which stores the user’s ID in the session. When the user requests a new page, their ID is read from the session variable.

Guards are configured in config/auth.php. The guard for web routes is called web.

Sanctum adds two more guards:

  • API Token Guard is for API requests, and uses a token in the Authorization HTTP header.
  • SPA Authentication Guard is for web applications, and is the one you will be using for impersonation.

The Sanctum is used by adding the auth:sanctum middleware to your routes. This tells it to load the ‘auth’ middleware (App\Http\Middleware\Authenticate, which extends Illuminate\Auth\Middleware\Authenticate) with the ‘sanctum’ guard (Illuminate\Auth\RequestGuard with a callback into Laravel\Sanctum\Guard).

The flow is as follows:

  • The page request invokes the auth middleware App\Http\Middleware\Authenticate.
  • This calls Illuminate\Auth\Middleware\Authenticate with the sanctum guard.
  • The sanctum guard is an Illuminate\Auth\RequestGuard with a callback of Laravel\Sanctum\Guard
  • Laravel\Sanctum\Guard tries to authorize with the sanctum.guard guard, which by default is the same as the web guard, which is Illuminate\Auth\SessionGuard. This checks to see if you have a variable login_[guard name]_[guard hash] in your session indicating that you are already logged in.
  • If the web guard cannot authenticate the user then it checks for an Authorization header. This step is only useful in API requests.

A Spanner in the Works

After setting the new user account, you may find that you become logged out. This is due to the middleware Illuminate\Session\Middleware\AuthenticateSession (or \Laravel\Jetstream\Http\Middleware\AuthenticateSession if you are using Jetstream).

The AuthenticateSession middleware handles the “log out other devices” functionality. It does this by storing a hash of the user’s password in the session, and logging the user out if their current password hash does not match the password hash in the session. Pressing the “log out other devices” button simply rehashes the user’s password.

The password hash is stored in the session after the controller runs, so it is possible to update it in your impersonation controller. This is shown in the next section.

Making Impersonation Work

Below is an example HTTP controller. It has two methods: impersonate() to change to a different user, and unimpersonate() to return to your original user account. While you are impersonating another user, your original user ID is stored in the session under impersonatingFrom.

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

/**
 * Handle user impersonation.
 */
class ImpersonationController extends Controller
{
    /**
     * Impersonate a user.
     *
     * @param Request $request The HTTP request.
     * @param User    $user    The user to impersonate.
     *
     * @return RedirectResponse Redirects to the dashboard.
     */
    public function impersonate(Request $request, User $user): RedirectResponse
    {
        // Make sure that the user is allowed to impersonate other users.
        // Use whatever is appropriate for your system.
        // $this->authorize('impersonate', User::class);
        // Gate::allowIf(fn (User $user) => $user->isAdministrator());
        // ...

        // Get the ID of the currently logged in user.
        $originalUserId = auth()->user()->id;

        // Log in as the user to impersonate.
        auth('web')->loginUsingId($user->id);

        // Reset the user for the AuthenticateSession middleware.
        $request->setUserResolver(fn () => $user);

        // Record the original user ID so we can unimpersonate later.
        $request->session()->put('impersonatingFrom', $originalUserId);

        return redirect()->route('dashboard');
    }

    /**
     * Stop impersonating a user.
     *
     * @param Request $request The HTTP request.
     *
     * @return RedirectResponse Redirects to the dashboard.
     */
    public function unimpersonate(Request $request): RedirectResponse
    {
        $userId = $request->session()->get('impersonatingFrom');

        if ($userId) {
            // Fetch the original user.
            $user = User::findOrFail($userId);

            // Switch to the original user account.
            auth('web')->loginUsingId($user->id);

            // Reset the user for the AuthenticateSession middleware.
            $request->setUserResolver(fn () => $user);

            // Remove the impersonation information from the session.
            $request->session()->forget('impersonatingFrom');
        }

        return redirect()->route('dashboard');
    }
}

The actual impersonation is done by these two lines:

auth('web')->loginUsingId($user->id);
$request->setUserResolver(fn () => $user);

The first line tells the web guard to switch to the new user, given the new user’s ID.

The second line sets the user that Illuminate\Session\Middleware\AuthenticateSession will use when storing the password hash in the session. This prevents it from logging you out on the next page load.