Skip to content
SaaS4Builders
Authentication

Impersonation

How platform admins can impersonate tenant users for debugging and support, with full audit logging and guard rails.

Platform admins can temporarily view the application as a specific tenant user. This is invaluable for debugging permission issues, verifying tenant configurations, and providing customer support without asking for credentials.

Impersonation is session-based, fully audited, and uses the lab404/laravel-impersonate package under the hood.


Guard Rails

Impersonation is tightly restricted. Six rules are enforced server-side before an impersonation session can start:

RuleEnforced By
Only platform admins can impersonateplatform.admin middleware + canImpersonate()
Cannot impersonate other platform adminscanBeImpersonated() check on target
Cannot impersonate yourselfimpersonator.id === target.id check
Target must belong to the specified tenantbelongsToTenant() check
Cannot start while already impersonatingmanager->isImpersonating() check
Admin routes are blocked during impersonationimpersonation.prevent middleware

All checks happen in the StartImpersonation action before any state change:

backend/app/Application/Impersonation/Actions/StartImpersonation.php
public function execute(StartImpersonationData $data): string
{
    $impersonator = User::findOrFail($data->impersonatorId);
    $target = User::findOrFail($data->targetUserId);

    if ($impersonator->id === $target->id) {
        throw ImpersonationException::cannotImpersonateSelf();
    }

    if (! $impersonator->canImpersonate()) {
        throw ImpersonationException::unauthorizedToImpersonate();
    }

    if (! $target->canBeImpersonated()) {
        throw ImpersonationException::cannotImpersonateAdmin();
    }

    if ($this->manager->isImpersonating()) {
        throw ImpersonationException::alreadyImpersonating();
    }

    if (! $target->belongsToTenant(Tenant::findOrFail($data->tenantId))) {
        throw ImpersonationException::targetNotInTenant();
    }

    // ... proceed with impersonation
}

The canImpersonate() and canBeImpersonated() methods are defined on the User model. By default, canImpersonate() returns true only for users with is_platform_admin === true, and canBeImpersonated() returns true for non-admin users.

The entire /admin route prefix uses the impersonation.prevent middleware. While impersonating, the admin cannot access any admin functionality — they are fully operating as the target user.

API Endpoints

Start Impersonation

POST /api/v1/admin/impersonation/start

Auth: auth:sanctumMiddleware: impersonation.prevent, platform.admin

FieldTypeRequiredDescription
user_idintegerYesID of the user to impersonate
tenant_idstring (UUID)YesTenant context for the impersonation

Response (200):

{
  "message": "Now impersonating Jane Smith"
}

Error Response (400):

{
  "message": "You cannot impersonate another platform admin."
}

Stop Impersonation

POST /api/v1/admin/impersonation/stop

Auth: auth:sanctumMiddleware: impersonation.active

No request body needed. Returns the admin user after restoring their identity:

Response (200):

{
  "data": {
    "id": 1,
    "name": "Admin User",
    "email": "admin@example.com",
    "is_platform_admin": true,
    "tenants": []
  },
  "message": "Impersonation ended"
}
The stop and status endpoints use impersonation.active middleware instead of platform.admin. During impersonation, the authenticated user is the target (not the admin), so platform.admin would reject the request.

Get Impersonation Status

GET /api/v1/admin/impersonation/status

Auth: auth:sanctumMiddleware: impersonation.active

Response (200):

{
  "data": {
    "is_impersonating": true,
    "impersonator_id": 1,
    "impersonator_name": "Admin User"
  }
}

Session Mechanism

Impersonation is session-based. Here is what happens when impersonation starts:

Starting Impersonation

backend/app/Application/Impersonation/Actions/StartImpersonation.php
// 1. Use the lab404 package to switch identities
$impersonator->impersonate($target);

// 2. lab404 uses quietLogin() which doesn't fire Login events.
//    Do a proper login() so AuthenticateSession middleware works.
auth()->guard('web')->login($target);

// 3. Restore lab404 session keys (cleared by the Login event listener)
session()->put($this->manager->getSessionKey(), $savedImpersonatorId);
session()->put($this->manager->getSessionGuard(), $savedGuard);
session()->put($this->manager->getSessionGuardUsing(), $savedGuardUsing);

// 4. Store tenant context and impersonated user ID
session()->put('current_tenant_id', $data->tenantId);
session()->put('impersonated_user_id', $data->targetUserId);

// 5. Fire domain event for audit logging
event(new ImpersonationStarted(
    impersonatorId: $data->impersonatorId,
    impersonatedId: $data->targetUserId,
    tenantId: $data->tenantId,
    ipAddress: $data->ipAddress,
    userAgent: $data->userAgent,
));
The explicit auth()->guard('web')->login($target) call after lab404's impersonate() is necessary because lab404 uses quietLogin(), which does not fire Laravel's Login event. Without the proper login, the AuthenticateSession middleware would invalidate the session on subsequent requests.

Stopping Impersonation

backend/app/Application/Impersonation/Actions/StopImpersonation.php
public function execute(StopImpersonationData $data): User
{
    if (! $this->manager->isImpersonating()) {
        throw ImpersonationException::notImpersonating();
    }

    // 1. Leave impersonation (lab404)
    $data->currentUser->leaveImpersonation();

    // 2. Proper login for the admin (same reason as start)
    $admin = User::findOrFail($data->impersonatorId);
    auth()->guard('web')->login($admin);

    // 3. Clear impersonation session data
    session()->forget('current_tenant_id');
    session()->forget('impersonated_user_id');

    // 4. Fire domain event for audit logging
    event(new ImpersonationEnded(...));

    return $admin;
}

The /auth/me Response During Impersonation

When impersonation is active, the GET /api/v1/auth/me endpoint includes an impersonation field in its response:

backend/app/Http/Controllers/Api/V1/Auth/AuthController.php
public function me(Request $request, TenantContext $tenantContext, ImpersonateManager $manager): JsonResponse
{
    $user = $request->user();

    $response = [
        'user' => new UserResource($user->load('tenants')),
        'currentTenant' => $tenantContext->get() ? new TenantResource($tenantContext->get()) : null,
    ];

    if ($manager->isImpersonating()) {
        $impersonatorId = $manager->getImpersonatorId();
        $impersonator = User::find($impersonatorId);

        $response['impersonation'] = [
            'is_impersonating' => true,
            'impersonator_id' => $impersonatorId,
            'impersonator_name' => $impersonator?->name,
        ];
    }

    return response()->json($response);
}

The frontend auth store reads this in its hydrateFromSession() method and sets the impersonation state, which drives the impersonation banner visibility and navigation behavior.

Example response during impersonation:

{
  "user": {
    "id": 42,
    "name": "Jane Smith",
    "email": "jane@example.com",
    "is_platform_admin": false
  },
  "currentTenant": {
    "id": "9f8a7b6c-...",
    "name": "Acme Inc."
  },
  "impersonation": {
    "is_impersonating": true,
    "impersonator_id": 1,
    "impersonator_name": "Admin User"
  }
}

Frontend Integration

useImpersonation Composable

The useImpersonation composable manages the full impersonation lifecycle:

frontend/features/product/platform/composables/useImpersonation.ts
const { startImpersonating, stopImpersonating, isStarting, isStopping, error } = useImpersonation()

// From the admin panel — start impersonating a tenant user
await startImpersonating(userId, tenantId)
// 1. Saves current URL to sessionStorage ('impersonation:returnUrl')
// 2. Calls POST /admin/impersonation/start
// 3. Refreshes auth store via refreshAfterSessionChange()
// 4. Shows success toast
// 5. Navigates to /dashboard

// From the impersonation banner — stop impersonating
await stopImpersonating()
// 1. Calls POST /admin/impersonation/stop
// 2. Refreshes auth store via refreshAfterSessionChange()
// 3. Retrieves saved URL from sessionStorage
// 4. Shows success toast
// 5. Navigates to saved URL (or /manager as fallback)

The refreshAfterSessionChange() method on the auth store is critical: it re-fetches the CSRF cookie (because the session ID changed), resets the API client's CSRF cache, and re-bootstraps via /auth/me.

Impersonation Banner

When authStore.isImpersonating is true, the ImpersonationBanner component renders a fixed banner at the top of the page:

frontend/features/product/platform/components/ImpersonationBanner.vue
<template>
  <div v-if="authStore.isImpersonating" ref="bannerRef" class="...">
    <span>Viewing as {{ authStore.user?.name }}</span>
    <UButton @click="stopImpersonating" :loading="isStopping">
      Leave
    </UButton>
  </div>
</template>

The component sets a --impersonation-banner-height CSS variable via useResizeObserver, so page layouts can offset their content to avoid overlap with the banner. When impersonation ends, the height is reset to 0px.


Middleware

Two middleware classes control access during impersonation sessions:

PreventDuringImpersonation

Alias: impersonation.prevent

Blocks access to routes that should not be used while impersonating. Returns 403 if impersonation is active.

backend/app/Http/Middleware/PreventDuringImpersonation.php
public function handle(Request $request, Closure $next): Response
{
    if ($this->manager->isImpersonating()) {
        abort(403, __('impersonation.blocked_during_impersonation'));
    }

    return $next($request);
}

Applied to: all /admin routes. This prevents the impersonating admin from accessing admin functionality while viewing the app as a tenant user.

EnsureImpersonationActive

Alias: impersonation.active

Ensures an impersonation session is currently active. Returns 400 if not impersonating.

backend/app/Http/Middleware/EnsureImpersonationActive.php
public function handle(Request $request, Closure $next): Response
{
    if (! $this->manager->isImpersonating()) {
        abort(400, __('impersonation.impersonation_required'));
    }

    return $next($request);
}

Applied to: the stop and status impersonation endpoints. These routes only make sense in the context of an active impersonation session.


Audit Logging

Every impersonation start and stop is logged for compliance and security auditing.

The impersonation_logs Table

ColumnTypeDescription
idbigintPrimary key
impersonator_idbigint (FK → users)The admin who initiated impersonation
impersonated_idbigint (FK → users)The user being impersonated
tenant_iduuid (FK → tenants)The tenant context
actionstringstarted or ended
ip_addressstring(45)Client IP address
user_agenttext (nullable)Browser user agent
created_attimestampWhen the action occurred

The table is indexed on (impersonator_id, created_at) and (impersonated_id, created_at) for efficient querying.

Domain Events

Two domain events drive the logging:

  • ImpersonationStarted — Dispatched from StartImpersonation::execute() after the session is established.
  • ImpersonationEnded — Dispatched from StopImpersonation::execute() after the session is terminated.

Both carry the same payload: impersonatorId, impersonatedId, tenantId, ipAddress, userAgent.

The LogImpersonation listener handles both events and creates the corresponding log entry:

backend/app/Listeners/Impersonation/LogImpersonation.php
public function handle(ImpersonationStarted|ImpersonationEnded $event): void
{
    ImpersonationLog::create([
        'impersonator_id' => $event->impersonatorId,
        'impersonated_id' => $event->impersonatedId,
        'tenant_id' => $event->tenantId,
        'action' => $event instanceof ImpersonationStarted ? 'started' : 'ended',
        'ip_address' => $event->ipAddress,
        'user_agent' => $event->userAgent,
    ]);
}

Extending Impersonation

Adding a New Guard Rail

To restrict impersonation further (e.g., block impersonation of users with a specific role), add a check in StartImpersonation::execute() before the impersonate() call:

if ($target->hasRole('sensitive-role')) {
    throw ImpersonationException::cannotImpersonateRole('sensitive-role');
}

Add a new factory method to ImpersonationException for the error message.

Restricting Who Can Be Impersonated

The canBeImpersonated() method on the User model controls which users can be targets. By default, it returns true for non-admin users:

backend/app/Models/User.php
public function canBeImpersonated(): bool
{
    return ! $this->isPlatformAdmin();
}

Override this method to add custom logic (e.g., exclude users in specific tenants or with specific flags).

Restricting Who Can Impersonate

Similarly, canImpersonate() controls who can start impersonation:

backend/app/Models/User.php
public function canImpersonate(): bool
{
    return $this->isPlatformAdmin();
}

You could extend this to require a specific permission, such as impersonation.manage, for finer-grained control.


What's Next