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.
| Approach | Complexity | Session safety | Edge cases handled | Dependency |
|---|---|---|---|---|
laravel-impersonate package | Low | Handled by package | Yes (2FA, remember-me, cross-guard) | One package |
DIY with Auth::loginUsingId() | Low (6 lines) | You manage it | No — you own every edge case | None |
| Laravel Nova’s built-in feature | N/A | Handled by Nova | Yes | Nova ($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:
- Used a trait on the User model (
Impersonatable) - Added
canImpersonate()andcanBeImpersonated()methods — authorisation lives where the data lives - Fired events (
StartedImpersonating,StoppedImpersonating) for audit logging - 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:
| File | Change |
|---|---|
composer.json | Added lab404/laravel-impersonate |
config/laravel-impersonate.php | Published package config |
config/auth.php | Added super_admins email allowlist |
config/logging.php | Added impersonation daily log channel |
.env.example | Added SUPER_ADMIN_EMAILS= |
app/Models/User.php | Added trait + 3 methods |
app/Providers/AppServiceProvider.php | Registered event listeners |
app/Listeners/LogImpersonationStarted.php | Audit logging |
app/Listeners/LogImpersonationEnded.php | Audit logging |
routes/web.php | Registered package routes |
resources/views/layouts/app.blade.php | Warning banner |
resources/views/livewire/settings/users/index.blade.php | ”Login as” button |
tests/Feature/ImpersonationTest.php | 7 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.