Skip to content
Go back

Adding User Impersonation to Laravel (The Right Way)

Two weeks ago, I removed the is_admin column from Growth Method and wrote an article about it. The argument: I didn’t need a super admin role because I was just a Manager on every team. The is_admin flag was a concept the codebase didn’t need.

That was true. What I did still need was the ability to log in as another user to debug their issues. Not a super admin role with scattered permission checks — just a way to see exactly what a customer sees, then switch back.

This is the story of adding that capability without undoing the simplification I’d just made.

What is impersonation?

Impersonation means temporarily logging in as another user. You click a button, the app logs you in as them, you see their dashboard, their team, their data. A banner at the top reminds you it’s not your real account. One click brings you back to yours.

It’s not a role. It’s not a permission bypass. It’s “let me see what this person sees” — a support tool, not an authorisation concept.

Three options I investigated

Before writing any code, I evaluated three approaches. Impersonation is security-sensitive — get the session handling wrong and you leak admin access or expose user data.

ApproachComplexitySession safetyEdge cases handledDependency
laravel-impersonate packageLowHandled by packageYes (2FA, remember-me, cross-guard)One package
DIY with Auth::loginUsingId()Low (6 lines)You manage itNo — you own every edge caseNone
Laravel Nova’s built-in featureN/AHandled by NovaYesNova ($299/year)

Option 1: The laravel-impersonate package

The 404labfr/laravel-impersonate package is the community standard. You add a trait to your User model and get routes, Blade directives, events, and middleware out of the box. About 3,500 GitHub stars, actively maintained.

Option 2: DIY with Auth::loginUsingId()

Laravel’s built-in Auth::loginUsingId() can log you in as any user. The core mechanism is about six lines:

// Start impersonating
session()->put('impersonator_id', auth()->id());
Auth::loginUsingId($targetId);

// Stop impersonating
Auth::loginUsingId(session()->pull('impersonator_id'));

Simple, but a security researcher flagged that naive implementations cause session bleed — the admin’s session data leaking to the impersonated user and vice versa. You’d need to handle session regeneration, remember-me tokens, and audit logging yourself.

Option 3: Laravel Nova

Taylor Otwell built impersonation into Laravel Nova using the same pattern — a trait on the User model, canImpersonate() and canBeImpersonated() methods, events for auditing. But Nova is a paid admin panel ($299/year), and installing it just for impersonation would be overkill.

The real questions I asked

Here are the actual questions from the session:

I’d like to add the ability for me (superadmin) to login as any user. What are the best options here? What are pros and cons? What would Taylor Otwell do?

And after the initial research:

Investigate 3 possible options. Pick your suggested solution and explain why, detail how the various options align or deviate from Laravel conventions and best practices. Check the laravel/framework on GitHub. Check for perspectives from well-known and respected Laravel sources. Answer my favourite question — what would Taylor Otwell do?

What Taylor Otwell actually did

We don’t have to guess — Taylor already built impersonation. When he needed it for Nova, he:

  1. Used a trait on the User model (Impersonatable)
  2. Added canImpersonate() and canBeImpersonated() methods — authorisation lives where the data lives
  3. Fired events (StartedImpersonating, StoppedImpersonating) for audit logging
  4. Used a service contract for testability

He also closed a PR (#51031) that proposed adding impersonation to the framework core, without commenting. The signal: impersonation belongs in a package, not the framework.

What Freek Van der Herten said

Freek Van der Herten (Spatie) explicitly recommends laravel-impersonate. Spatie has over 100 Laravel packages and deliberately chose not to build their own impersonation package — a strong endorsement of the existing one.

Why I chose the package

The DIY approach is simpler in line count, but it’s not simpler in correctness. The package handles session regeneration, cross-guard auth, Blade directives, protection middleware, and fires events for auditing. That’s the kind of security-sensitive plumbing I don’t want to maintain myself.

The package follows the same architecture as Nova’s impersonation — trait on model, authorisation methods, events.

How I implemented it

Superadmin as a config value, not a database column

I’d just removed is_admin from the database. I wasn’t about to add it back. Instead, I used an environment variable:

// config/auth.php
'super_admins' => array_filter(
    array_map('trim', explode(',', env('SUPER_ADMIN_EMAILS', '')))
),
# .env
SUPER_ADMIN_EMAILS=stuart@example.com

No migration. No new column. One line in .env controls who can impersonate.

Wiring up the User model

Three methods on the User model, plus the package’s trait:

use Lab404\Impersonate\Models\Impersonate;

class User extends Authenticatable
{
    use Impersonate;

    public function isSuperAdmin(): bool
    {
        return in_array($this->email, config('auth.super_admins', []), true);
    }

    public function canImpersonate(): bool
    {
        return $this->isSuperAdmin();
    }

    public function canBeImpersonated(): bool
    {
        return ! $this->isSuperAdmin();
    }
}

canImpersonate() and canBeImpersonated() are the package’s authorisation hooks. The package checks these before allowing any impersonation — superadmins can impersonate regular users, but nobody can impersonate a superadmin.

The impersonation banner

The package provides @impersonating and @endImpersonating Blade directives. I added a sticky warning banner at the top of the app layout:

@impersonating
<div class="sticky top-0 z-50">
    <flux:callout icon="exclamation-triangle" variant="warning" inline>
        <flux:callout.heading>
            Impersonating {{ auth()->user()->name }}
            ({{ auth()->user()->email }})
        </flux:callout.heading>
        <x-slot name="actions">
            <flux:button size="sm" href="{{ route('impersonate.leave') }}">
                Return to my account
            </flux:button>
        </x-slot>
    </flux:callout>
</div>
@endImpersonating

Audit logging

Dedicated listener classes log every impersonation start and stop to a separate log file:

class LogImpersonationStarted
{
    public function handle(TakeImpersonation $event): void
    {
        Log::channel('impersonation')->info('Impersonation started', [
            'admin_id' => $event->impersonator->id,
            'admin_email' => $event->impersonator->email,
            'target_id' => $event->impersonated->id,
            'target_email' => $event->impersonated->email,
            'ip' => request()->ip(),
        ]);
    }
}

The “Login as” button

On the Settings > Users page, superadmins see a “Login as” button next to each user:

@if (auth()->user()->canImpersonate()
    && $user->canBeImpersonated()
    && auth()->id() !== $user->id)
    <flux:button size="sm" href="{{ route('impersonate', $user->id) }}">
        Login as
    </flux:button>
@endif

Regular users never see this button.

What the tests look like

Seven Pest tests cover the key scenarios:

test('superadmin can impersonate a regular user', function () {
    $response = $this->actingAs($this->admin)
        ->get(route('impersonate', $this->user->id));

    $response->assertRedirect('/');
    $this->assertAuthenticatedAs($this->user);
});

test('regular user cannot impersonate anyone', function () {
    $response = $this->actingAs($this->user)
        ->get(route('impersonate', $this->admin->id));

    $response->assertForbidden();
    $this->assertAuthenticatedAs($this->user);
});

The full file list

No migrations. No new database tables. Thirteen files changed:

FileChange
composer.jsonAdded lab404/laravel-impersonate
config/laravel-impersonate.phpPublished package config
config/auth.phpAdded super_admins email allowlist
config/logging.phpAdded impersonation daily log channel
.env.exampleAdded SUPER_ADMIN_EMAILS=
app/Models/User.phpAdded trait + 3 methods
app/Providers/AppServiceProvider.phpRegistered event listeners
app/Listeners/LogImpersonationStarted.phpAudit logging
app/Listeners/LogImpersonationEnded.phpAudit logging
routes/web.phpRegistered package routes
resources/views/layouts/app.blade.phpWarning banner
resources/views/livewire/settings/users/index.blade.php”Login as” button
tests/Feature/ImpersonationTest.php7 Pest tests

Things to watch out for

2FA bypass. Impersonation skips two-factor authentication for the target user. That’s the whole point — you need to be able to log in without their phone. The email allowlist and audit log are the mitigation.

Queued jobs. Jobs dispatched while impersonating will run as the impersonated user (because they capture Auth::id() at dispatch). For debugging, that’s what you want. For admin operations, switch back first.

Sentry context. If you tag error reports with the auth user, impersonated sessions will look like the target user caused the error. Consider adding the impersonator’s ID as extra context.

The pattern that emerged

Two weeks ago: remove is_admin because it was a concept the codebase didn’t need.

Today: add impersonation because it solves a real problem (debugging customer issues) without reintroducing the concept I removed.

The difference is scope. is_admin was a permanent authorisation bypass scattered across six files. Impersonation is a temporary support tool with an audit trail, gated by a config value, visible via a banner, and reversible with one click.

The principle is the same both times: add what you need, remove what you don’t.

Related: Do You Actually Need a Super Admin? explains why I removed is_admin in the first place. Removing Spatie Permissions for a Single Laravel Gate covers the broader authorisation simplification that preceded this work.


Back to top ↑