Documentation menu
HomeDocsAuth & SecurityImpersonation

Impersonation

Lets a privileged operator log in as another user for the duration of a session, then return to their own account with one click. Shipped in v0.10.

Why this exists

Support, debugging, and customer-success workflows often need an operator to see exactly what another user sees. Impersonation is the right primitive when you trust the operator and audit-log every session.

The two alternatives, copying production data into staging or stashing magic links, are slow and lossy.

Two-layer guard

Impersonation is opt-in. Both layers below must say "yes" before the start endpoint succeeds:

  1. Master switch — martis.impersonation.enabled (default false). Set MARTIS_IMPERSONATION_ENABLED=true in the env to flip it on.
  2. Authorisation gate — martis-impersonate. The package ships no default: you define it explicitly. This is on purpose — admins should not become impersonators by accident.

When the master switch is off, every endpoint returns 503. When the gate denies the request, 403. The two states are deliberately distinct so the audit trail can tell "globally disabled" apart from "per-user denied".

Configuration

// config/martis.php
'impersonation' => [
'enabled' => env('MARTIS_IMPERSONATION_ENABLED', false),
'guard' => env('MARTIS_IMPERSONATION_GUARD'), // null: the Martis guard
'session_key' => env('MARTIS_IMPERSONATION_SESSION_KEY', 'martis.impersonation'),
'max_duration_minutes' => (int) env('MARTIS_IMPERSONATION_MAX_DURATION', 0),
],
KeyDefaultPurpose
enabledfalseMaster switch.
guardnull (the Martis guard)Auth guard the impersonation operates on: the operator and the target are users of that guard. Unset, it follows MARTIS_GUARD (v2.0.0+; it was web). With a custom Martis guard that means another admin; to impersonate the users of the site's guard, set it to that guard, on which the operator must then be signed in too.
session_keymartis.impersonationSession bag where the operator's id is stashed. Change it for cross-tenant isolation.
max_duration_minutes0 (disabled)Auto-stop the session after N minutes of impersonation. The martis.impersonation.duration middleware (registered automatically on every protected Martis route) compares started_at against now() and calls stop() when the window has elapsed. Use it to prevent forgotten impersonations from leaking access. v1.8.8.

Defining the gate

Drop this in App\Providers\AuthServiceProvider::boot():

use Illuminate\Support\Facades\Gate;
 
Gate::define('martis-impersonate', function ($user) {
return $user?->isSuperAdmin();
// Or: $user?->hasRole('support-lead');
// Or: $user?->email && str_ends_with($user->email, '@yourcompany.com');
});

The closure receives the operator first and, since v2.4.0, the target as its second argument. A gate that only judges the operator keeps working as it is (fn ($user) => ... ignores the second argument), but it then admits every target: in one users table that holds both support staff and super-admins, an operator the gate lets in could borrow the identity of any user, a super-admin included, and act with that user's authority. NotImpersonable is per model class, so it cannot protect one row of a shared table. Judge the pair instead:

Gate::define('martis-impersonate', function ($operator, $target) {
// A support operator may impersonate users that do not outrank them.
return $operator->hasRole('support-lead')
&& $target->roles->max('level') <= $operator->roles->max('level');
});

The gate runs after the target is loaded. For an id that does not exist it runs with the operator alone: a gate that needs the target answers 403 there, like any other denial (and so does one that raises any error on the missing target, such as a $target = null closure that reads $target->rank: an error there is a refusal, never a 500), and a gate that only judges the operator (and refuses) answers 403 too, so an operator who may not impersonate learns nothing about which ids exist from a 404. Give the second parameter a default ($target = null) if the same gate also serves a call without a target. Up to v2.3.0 the gate never saw the target; a one-argument gate written then is still called the same way, and still lets the operator impersonate whoever they ask for: add the second argument to restrict that.

Per-instance hooks

Like Nova's canImpersonate() and canBeImpersonated(), two optional methods on the user model decide per row (v2.4.0):

class User extends Authenticatable
{
// The operator: false answers 403, whatever the gate says.
public function canImpersonate(): bool
{
return $this->is_support;
}
 
// The target: false answers 422, as NotImpersonable does.
public function canBeImpersonated(): bool
{
return ! $this->is_super_admin;
}
}

They are checked on top of the gate and NotImpersonable, and again inside ImpersonationManager::start(), so a programmatic Impersonation::start($user) cannot go around them (it throws Martis\Impersonation\ImpersonationRefusedException, a RuntimeException).

REST surface

MethodPathReturns
GET/martis/api/impersonation/statusSnapshot — { active, enabled, original, target, started_at }
POST/martis/api/impersonation/start/{userId}Snapshot after starting (200), or 503 / 403 / 404 / 422
POST/martis/api/impersonation/stopSnapshot after stopping (200)

Snapshot shape

{
"active": true,
"enabled": true,
"original": { "id": 1, "label": "Operator Smith" },
"target": { "id": 7, "label": "Target Jones" },
"started_at": "2026-04-27T15:42:11+00:00"
}

label is the user's name, falling back to email, falling back to null. started_at is ISO 8601 UTC. The frontend banner uses these fields directly.

Start — error matrix

HTTPCause
503Master switch off.
403martis-impersonate gate returned false (for this target: the gate receives it as its second argument, v2.4.0), or the operator model's canImpersonate() hook returned false.
404Target user id does not exist on the configured guard's user provider.
422Operator tried to impersonate themselves, or impersonation is already active (chaining is not supported on purpose), or the target is NotImpersonable or its canBeImpersonated() hook returned false, or the target fails the viewMartis panel gate (v2.1.0).
200Started — body is the active snapshot.

If the viewMartis gate stops accepting the impersonated user during the session, every panel route refuses them except POST /api/impersonation/stop, and the no-access screen offers Stop impersonating (v2.1.0). See Authorization → Panel access.

Stop

POST /martis/api/impersonation/stop always returns 200 and the (now-inactive) snapshot. Calling it when no impersonation is running is a no-op.

Frontend banner

The package ships a fully functional banner — resources/js/components/ImpersonationBanner.tsx — that the bundled Layout.tsx renders right above every page (<main class="martis-shell-content"> > <ImpersonationBanner />). It polls /martis/api/impersonation/status on every shell mount, surfaces the current target's label + a "Stop impersonating" button, and reloads the SPA on stop. Consumers do not need to wire anything for the basic flow.

The poll cadence is configurable via martis.impersonation.poll_interval (env MARTIS_IMPERSONATION_POLL_MS, default 120 000 ms / 2 min, raised from 30 s in v1.8.8). Set to 0 to disable polling — the banner still mounts and reads state once per page load. The banner short-circuits the entire effect when martis.impersonation.enabled is false at boot, so installs that never enable the feature pay zero ongoing cost.

Overriding the banner

Register a custom React component under the canonical registry key from your consumer extension bundle (resources/js/martis-extensions/):

import { componentRegistry } from '@martis/runtime'
import { MyBrandedImpersonationBanner } from './components/MyBrandedImpersonationBanner'
 
componentRegistry.register('impersonation:banner', MyBrandedImpersonationBanner)

The Layout resolves the override on every render (same pattern as loader, martis:profile-sessions, martis:drawer-create, auth:login); a missing registration falls back to the bundled default. Use this when you want to:

  • Add an audit-reason chip ("Impersonating to investigate ticket #123").
  • Swap the colour for a different security accent.
  • Render the banner inside a different shell slot.

Your override is responsible for fetching /martis/api/impersonation/status itself — there is no shared store. Polling cadence and stop UX are entirely yours.

Trigger UX

Two common patterns to surface the action:

  1. Inline action on the Users resource — register an Action that posts to /martis/api/impersonation/start/{id} for the selected user. Restrict via canSee() to the gate.
  2. User-detail toolbar button — render a button in ResourceDetail for the User resource, gated by martis-impersonate.

Either way the call is a plain POST — no special middleware.

Per-target blacklist (NotImpersonable)

Some users must never be impersonated — system / API accounts, super-admins, anything where letting an operator borrow the identity would be a security footgun. Mark them with the Martis\Contracts\NotImpersonable interface:

namespace App\Models;
 
use Illuminate\Foundation\Auth\User as Authenticatable;
use Martis\Contracts\NotImpersonable;
 
class SystemAccount extends Authenticatable implements NotImpersonable
{
}

ImpersonationManager::start() checks for the interface before mutating the session and rejects with ImpersonationRefusedException (a RuntimeException), which the controller surfaces as 422 { message: "This user cannot be impersonated." }. To protect some rows of a table and not others, use the target-aware gate or canBeImpersonated() above. The check runs server-side; the operator's UI is unchanged. You typically pair this with a canSee() clause on the trigger button (the row never shows the option) for the cleanest UX. Only that exception type is shown to the client: any other failure while starting (a database or session error) goes to the normal exception handler and never reaches the response body.

Audit logging

Since v1.8.8 every successful start() / stop() is recorded into the martis_action_events audit log automatically. The Martis-shipped listener subscribes to two events (also new in v1.8.8) and writes one row per dispatch:

EventAction name
Martis\Impersonation\Events\ImpersonationStartedimpersonation.started
Martis\Impersonation\Events\ImpersonationStoppedimpersonation.stopped

Each row carries:

  • user_id: the operator (the user issuing the impersonation, even after the auth guard switched to the target), when the impersonation guard signs in the Martis guard's users (the default). When guard names a guard of other users (the site's), the log's user(), which resolves the Martis guard's model, would name someone else: user_id is null and the operator goes to fields.operator_type / fields.operator_id (v2.0.0+).
  • model_id / target_id: the target user.
  • fields.target_label: the target's name, falling back to email (mirrors the snapshot label).
  • fields.operator_id / fields.operator_label: the operator beside the target, whoever the impersonation guard signs in, so a row names both people even when user_id cannot (v2.4.0).

Browse them under /martis/system/action-events (or whatever URL the bundled ActionEventResource lives at), once the view-martis-action-events gate lets you (see Actions → Who can read the audit log). Toggle the audit-row write per-environment via MARTIS_AUDIT_IMPERSONATION=false: the events still fire so any custom listeners you attach keep firing; only the Martis row is suppressed.

Custom listeners

The events are public, so consumer apps can attach their own observers without subclassing the manager:

use Martis\Impersonation\Events\ImpersonationStarted;
use Illuminate\Support\Facades\Event;
 
Event::listen(ImpersonationStarted::class, function (ImpersonationStarted $e) {
\App\Notifications\SecurityWebhook::send([
'operator_id' => $e->operator->getAuthIdentifier(),
'target_id' => $e->target->getAuthIdentifier(),
]);
});

The two events live under Martis\Impersonation\Events\* and carry both operator and target as Authenticatable instances. Subscribe from AppServiceProvider::boot() or your own EventServiceProvider.

Security notes

  • The package never bypasses the gate. Removing the gate definition disables impersonation entirely.
  • While an impersonation runs, the profile endpoints that change the target's second factor or password (2FA setup, confirm and disable, the recovery codes, the password change) answer 403 and change nothing: an operator cannot leave themselves a way into the account that outlives the session (v2.4.0). See Authentication → Recovery Codes.
  • start() rejects self-impersonation and chaining (impersonator A starting impersonation as B while already impersonating C). Either path is a sign of a confused state — fail loud.
  • The session marker is namespace-prefixed (martis.impersonation) so it cannot accidentally collide with host-app session data.
  • The frontend banner must read /martis/api/impersonation/status on every page load. Do not cache the snapshot — a session can be stopped server-side at any moment.

Tests

Behaviour-level coverage lives in tests/Feature/ImpersonationControllerTest.php (the full error matrix, NotImpersonable rejection, the target-aware gate and the canImpersonate / canBeImpersonated hooks, max-duration auto-stop via the middleware, event dispatch and audit rows on start + stop). The ParitySurface tripwire (tests/Feature/ParitySurfaceTest.php) asserts the public surface — ImpersonationManager::start/stop/isActive/isExpired/originalUser/currentTarget/enabled/guard/snapshot — keeps its contract.