User impersonation in Laravel without opening a security hole

November 2, 2026 Ā· Estimated reading time: less than a minute

LaravelSecurity

User impersonation in Laravel without opening a security hole

A support ticket says "the invoice page is empty for this customer". You can't reproduce it with your own account, and asking the customer for screenshots costs a day per round trip. Signing in as that user fixes the problem in minutes.
It is also the most dangerous feature you can add to an app. Every rule you skip becomes a way to take over an account, so I treat impersonation as a security feature first and a support feature second.
šŸ“
TODO (author): say in two or three sentences which approach you used at NicaraoAgency (the lab404/laravel-impersonate package, your own implementation, or a mix) and what kind of tickets it helped with. Remove any detail you can't share. Nothing below claims a specific incident.

Start with the rules, not the code

Before writing anything I settle these rules. Each one maps to code later.
  • Only a small, explicit set of staff roles can impersonate.
  • Nobody can impersonate themselves, another staff member or an administrator.
  • The original user is stored on the server, never in something the browser can edit.
  • A banner is visible on every page while impersonating, with a one-click exit.
  • Sensitive actions are blocked while impersonating: password, email, payment methods, API tokens.
  • Start and stop are written to an audit log with who, whom and when.
The most common mistake is the second rule. If a support agent can impersonate an administrator, you have given every support agent the administrator's powers.

Use a package for the session switching

Switching the authenticated user and getting back reliably has edge cases: guards, remember-me cookies, redirects. I would rather not maintain that myself. lab404/laravel-impersonate handles it, and its README lists support for Laravel 6.x through 13.x.
composer require lab404/laravel-impersonate
Add the trait to the user model and register the route macro:
// app/Models/User.php use Lab404\Impersonate\Models\Impersonate; class User extends Authenticatable { use Impersonate; }
// routes/web.php Route::impersonate();
The macro registers the impersonate and impersonate.leave routes. The package also keeps the original user's id in the session under the impersonated_by key by default, so the server, not the browser, knows who you really are. You can change the key in the published config (php artisan vendor:publish --tag=impersonate).

Decide who can impersonate whom

By default the package lets everyone impersonate everyone. That default is wrong for any real app, so the two hooks are the first thing to change.
// app/Models/User.php public function canImpersonate(): bool { return $this->hasRole('support'); } public function canBeImpersonated(): bool { return ! $this->hasAnyRole(['support', 'admin']); }
canImpersonate() answers who may start. canBeImpersonated() answers who may be a target. Support staff cannot be targets, so a compromised support account cannot be used to reach an administrator.
The self check is not in these hooks, and as far as I can tell from the package's ImpersonateManager, it isn't in take() either. If you want a hard guarantee, check it where you control the flow. I add a small guard in front of the route:
// routes/web.php Route::middleware(['auth', 'can:impersonate,user']) ->get('/impersonate/{user}', [ImpersonationController::class, 'start']) ->name('impersonate.start');
// app/Policies/UserPolicy.php public function impersonate(User $actor, User $target): bool { return $actor->canImpersonate() && $target->canBeImpersonated() && ! $actor->is($target); }
// app/Http/Controllers/ImpersonationController.php public function start(Request $request, User $user) { $request->user()->impersonate($user); return redirect()->route('dashboard'); }
Now the rules live in a policy you can unit test, and the controller only runs after the policy passes. If your app uses a Gate::before hook that lets administrators pass every check, remember that it will also pass this one. Read the authorization docs: a non-null result from before becomes the final answer.
šŸ“
TODO (author): list the real rules you enforced (who could impersonate, who was off limits) and correct the ones above if they differ. Keep it to the rules, no customer or company details.

Make it impossible to forget you are impersonating

If the agent doesn't notice they are in someone else's account, they will eventually click something they shouldn't. The package ships a Blade directive for the banner:
{{-- resources/views/layouts/app.blade.php --}} @impersonating <div class="impersonation-banner" role="alert"> You are viewing the app as {{ auth()->user()->name }}. <a href="{{ route('impersonate.leave') }}">Return to my account</a> </div> @endimpersonating
Put it in the main layout, above the navigation, so no page can skip it. Make it a loud colour. A subtle banner is the same as no banner.

Block the actions that cause damage

Seeing what the customer sees is the goal. Changing their credentials is not. The package provides the impersonate.protect middleware, which rejects impersonators on a route:
Route::middleware(['auth', 'impersonate.protect'])->group(function () { Route::put('/settings/password', [PasswordController::class, 'update']); Route::put('/settings/email', [EmailController::class, 'update']); Route::resource('/settings/payment-methods', PaymentMethodController::class); Route::post('/settings/api-tokens', [ApiTokenController::class, 'store']); });
My rule of thumb: if an action could let someone log in later without the support agent (password, email, tokens), or move money, it goes in that group. Everything else stays open, because support needs to reproduce the bug, not just look at it.
One trade-off: the group is a deny list. A new sensitive route that nobody adds to it is silently open. Writing a test that walks the route list and fails when a route under /settings has no protection is cheap and catches this.

Write an audit entry for start and stop

The package dispatches TakeImpersonation and LeaveImpersonation events, each with the impersonator and impersonated users. They are the natural place for the audit trail:
// app/Listeners/LogImpersonation.php use Lab404\Impersonate\Events\LeaveImpersonation; use Lab404\Impersonate\Events\TakeImpersonation; class LogImpersonation { public function handle(TakeImpersonation|LeaveImpersonation $event): void { ImpersonationLog::create([ 'impersonator_id' => $event->impersonator->getKey(), 'impersonated_id' => $event->impersonated->getKey(), 'action' => $event instanceof TakeImpersonation ? 'start' : 'stop', 'ip_address' => request()->ip(), ]); } }
If you already record model changes, this is the missing half: a normal audit log would attribute actions made during impersonation to the customer. Store the impersonator on every audit entry made during a session, using the id the package keeps in the session, or you lose the one fact an investigation needs.
šŸ“
TODO (author): add one concrete example of where this helped (a type of ticket, how long it took before vs after, if you can share it) or delete this callout. Do not add numbers you can't back up.

Test the rules

The policy is plain PHP, so the tests are short and are the real safety net:
it('does not let support impersonate an admin', function () { $support = User::factory()->support()->create(); $admin = User::factory()->admin()->create(); expect($support->can('impersonate', $admin))->toBeFalse(); }); it('does not let anyone impersonate themselves', function () { $support = User::factory()->support()->create(); expect($support->can('impersonate', $support))->toBeFalse(); });
Add one feature test that logs in as a target, requests the password route and expects a rejection. It proves the middleware is wired, not only the policy.
If you want more on keeping Laravel code in a shape that is easy to test and review, see Laravel Best Practice Coding Standards.

What I'd do next time

  • Start from "nobody can impersonate" and open it up role by role, not the reverse.
  • Make impersonate.protect the default for every settings route, and opt out explicitly.
  • Store the impersonator on every audit entry from day one.
  • Add a time limit to the impersonation session, so a forgotten tab doesn't stay logged in as a customer for hours.
  • Tell customers in your terms or privacy policy that authorised staff may view their account for support.
Impersonation should feel boring: a button, a banner, a log line. If it feels clever, check the rules again.
// Share this post
XLinkedIn
Ernesto Herrera portrait
// Written by

Ernesto Herrera

Software engineer based in Nicaragua, writing about software development, productivity and life.

Get in touch

Related posts

[03]
  • October 1, 2023

    Laravel Best Practice Coding Standards

    LaravelBestPratices
  • September 28, 2023

    Laravel conventions

    LaravelBestPratices
  • October 26, 2026

    Using Notion as a headless CMS for a Next.js blog

    Next.jsNotion
Ā© 2026 ErnerdoNext.js + Chakra UI + NotionMade in Nicaragua