Skip to content

User Impersonation ​

User impersonation allows authorized users (typically administrators) to temporarily authenticate as another user for debugging, support, or testing purposes. This feature provides a secure way to troubleshoot user-specific issues while maintaining full audit logging.

Features ​

  • Opt-in Configuration: Disabled by default, must be explicitly enabled
  • Short-lived Sessions: Impersonated sessions expire quickly (15 minutes default)
  • No Token Refresh: Impersonated sessions cannot be refreshed for security
  • Audit Logging: Full audit trail via Nuxt server hooks
  • Chain Prevention: Cannot impersonate while already impersonating
  • Custom Authorization: Implement your own authorization logic via hooks
  • Original Context Access: Server routes can access both current and original user

Configuration ​

Enable impersonation in your nuxt.config.ts:

typescript
export default defineNuxtConfig({
  modules: ['@peterbud/nuxt-aegis'],
  
  nuxtAegis: {
    impersonation: {
      enabled: true,
      tokenExpiration: 900, // 15 minutes in seconds (default)
    },
  },
})

Configuration Options ​

OptionTypeDefaultDescription
enabledbooleanfalseEnable or disable impersonation feature
tokenExpirationnumber900Impersonated token expiration in seconds
originalUserLookupClaimstring'sub'Token claim used to refetch the original user when ending impersonation

originalUserLookupClaim is useful when your JWT sub is a provider-specific identifier but your impersonation.fetchTarget() handler expects an application-level identifier such as userId.

Required Handlers ​

To use impersonation, you must implement the impersonation handlers using defineAegisHandler in a server plugin.

Implementation Example ​

typescript
// server/plugins/aegis.ts
export default defineNitroPlugin((nitroApp) => {
  defineAegisHandler({
    impersonation: {
      // 1. Authorization Check (Optional, defaults to allowing if fetchTarget succeeds)
      canImpersonate: async (requester, targetId, event) => {
        // Check if user is allowed to impersonate
        if (requester.role !== 'admin') {
          return false
        }
        return true
      },

      // 2. Fetch Target User (Required)
      fetchTarget: async (targetUserId, event) => {
        // Fetch the target user from your database
        const targetUser = await getUserById(targetUserId)
        
        if (!targetUser) {
          return null
        }
        
        // Optional: Additional authorization checks
        if (requester.organizationId !== targetUser.organizationId) {
           // You can throw errors for specific messages
           throw createError({
             statusCode: 403,
             message: 'Cannot impersonate users from different organizations',
           })
        }
        
        // Return user data that will become JWT claims
        return {
          sub: targetUser.id,
          email: targetUser.email,
          name: targetUser.name,
          picture: targetUser.picture,
          role: targetUser.role,
          permissions: targetUser.permissions,
          // ... any other custom claims
        }
      }
    }
  })
})

Optional Audit Hooks ​

Implement these hooks for audit logging:

Start Impersonation Hook ​

typescript
import { useServerHooks } from 'nuxt/server'

useServerHooks().hook('nuxt-aegis:impersonate:start', async (payload) => {
  // Log impersonation start
  await auditLog.create({
    action: 'IMPERSONATION_START',
    adminId: payload.requester.sub,
    adminEmail: payload.requester.email,
    targetId: payload.targetUser.sub,
    targetEmail: payload.targetUser.email,
    reason: payload.reason,
    ip: payload.ip,
    userAgent: payload.userAgent,
    timestamp: payload.timestamp,
  })
})

End Impersonation Hook ​

typescript
import { useServerHooks } from 'nuxt/server'

useServerHooks().hook('nuxt-aegis:impersonate:end', async (payload) => {
  // Log impersonation end
  await auditLog.create({
    action: 'IMPERSONATION_END',
    adminId: payload.restoredUser.sub,
    adminEmail: payload.restoredUser.email,
    wasImpersonatingId: payload.impersonatedUser.sub,
    wasImpersonatingEmail: payload.impersonatedUser.email,
    ip: payload.ip,
    userAgent: payload.userAgent,
    timestamp: payload.timestamp,
  })
})

Client-Side Usage ​

The useAuth() composable provides methods and properties for impersonation:

vue
<script setup lang="ts">
const { 
  user,
  isImpersonating, 
  originalUser, 
  impersonate, 
  stopImpersonation 
} = useAuth()

const targetUserId = ref('')
const reason = ref('')

async function handleImpersonate() {
  try {
    await impersonate(targetUserId.value, reason.value)
    console.log('Now impersonating:', user.value?.email)
  } catch (error) {
    console.error('Impersonation failed:', error)
  }
}

async function handleStopImpersonation() {
  try {
    await stopImpersonation()
    console.log('Restored to:', user.value?.email)
  } catch (error) {
    console.error('Failed to stop impersonation:', error)
  }
}
</script>

<template>
  <div>
    <!-- Impersonation Banner -->
    <div v-if="isImpersonating" class="banner">
      <p>
        You ({{ originalUser?.originalUserEmail }}) are impersonating 
        {{ user?.email }}
      </p>
      <button @click="handleStopImpersonation">
        Stop Impersonation
      </button>
    </div>
    
    <!-- Admin Controls -->
    <div v-if="user?.role === 'admin' && !isImpersonating">
      <h3>Impersonate User</h3>
      <input v-model="targetUserId" placeholder="User ID or Email" />
      <textarea v-model="reason" placeholder="Reason (optional)" />
      <button @click="handleImpersonate">Start Impersonation</button>
    </div>
  </div>
</template>

Composable API ​

Properties ​

  • isImpersonating: ComputedRef<boolean> - True when currently impersonating
  • originalUser: ComputedRef<{ originalUserSub, originalUserEmail, originalUserName } | null> - Original user subject and profile data when impersonating

Methods ​

  • impersonate(targetUserId: string, reason?: string): Promise<void> - Start impersonating another user
  • stopImpersonation(): Promise<void> - End impersonation and restore original session

Server-Side Usage ​

Server routes can access both the impersonated user and the original user:

typescript
// server/routes/api/admin/action.post.ts
export default defineEventHandler(async (event) => {
  const user = event.context.user // Current user (impersonated if active)
  const originalUser = event.context.originalUser // Original user (if impersonating)
  
  if (originalUser) {
    // User is impersonating
    console.log(`Admin ${originalUser.email} is impersonating ${user.email}`)
    
    // You can enforce additional restrictions
    if (someSensitiveAction) {
      throw createError({
        statusCode: 403,
        message: 'This action cannot be performed while impersonating',
      })
    }
  }
  
  // Perform action as current user
  return performAction(user)
})

Context Properties ​

When impersonating, the middleware injects:

  • event.context.user: Current user (the impersonated user)
  • event.context.originalUser: Original user who started impersonation (if applicable)
typescript
interface OriginalUserContext {
  sub: string
  email?: string
  name?: string
}

Security Considerations ​

Token Characteristics ​

  • Short Expiration: Impersonated tokens expire in 15 minutes (configurable)
  • No Refresh: Impersonated sessions cannot be refreshed - user must re-impersonate
  • Chain Prevention: Cannot impersonate while already impersonating another user
  • Immediate Validation: Authorization is checked on every impersonation attempt

Best Practices ​

  1. Restrict to Admins: Implement strict role checks in the canImpersonate handler
  2. Require Reasons: Always prompt for and log the reason for impersonation
  3. Organization Boundaries: Consider preventing impersonation across organization boundaries
  4. Audit Everything: Log all impersonation events with full context
  5. Time Limits: Keep the token expiration short (default 15 minutes)
  6. Sensitive Actions: Block certain actions when impersonating (e.g., password changes, account deletion)
  7. Notification: Consider notifying users when they're impersonated (optional)

Example Authorization Logic ​

typescript
defineAegisHandler({
  impersonation: {
    canImpersonate: async (requester, targetId) => {
      if (requester.role !== 'admin' && requester.role !== 'support') return false
      if (!requester.permissions?.includes('impersonate')) return false

      const target = await db.users.findById(targetId)
      return !!target && target.organizationId === requester.organizationId
    },
    fetchTarget: targetId => db.users.findById(targetId),
  },
})

JWT Structure ​

When impersonating, the JWT includes an impersonation object:

json
{
  "sub": "target-user-id",
  "email": "target@example.com",
  "name": "Target User",
  "role": "user",
  "impersonation": {
    "originalUserSub": "auth-provider-admin-sub",
    "originalUserLookupId": "database-admin-id",
    "originalUserEmail": "admin@example.com",
    "originalUserName": "Admin User",
    "impersonatedAt": "2024-01-15T10:30:00.000Z",
    "reason": "Debugging issue #123",
    "originalClaims": {
      "role": "admin",
      "permissions": ["read", "write", "delete"]
    }
  },
  "iat": 1705315800,
  "exp": 1705316700,
  "iss": "nuxt-aegis"
}

The originalClaims field stores all custom claims from the original user's token, ensuring they can be fully restored when impersonation ends.

API Endpoints ​

Start Impersonation ​

http
POST /auth/impersonate
Authorization: Bearer {admin-token}
Content-Type: application/json

{
  "targetUserId": "user-id-or-email",
  "reason": "Debugging user issue #123"
}

Response:

json
{
  "accessToken": "eyJhbGc..."
}

Stop Impersonation ​

http
POST /auth/unimpersonate
Authorization: Bearer {impersonated-token}

Response:

json
{
  "accessToken": "eyJhbGc..."
}

A new refresh token cookie is also set to restore the full session.

Error Handling ​

Common error scenarios:

StatusErrorDescription
404Feature not enabledImpersonation is disabled in configuration
403Insufficient permissionsUser doesn't have permission to impersonate
403Cannot chain impersonationAlready impersonating another user
404Target user not foundUser ID doesn't exist
403Organization mismatchCannot impersonate users from other organizations
400Not impersonatingTried to stop impersonation when not impersonating
403Cannot refreshTried to refresh an impersonated session

TypeScript Types ​

typescript
import type { NuxtRequestEvent, RequestEventContext } from 'nuxt/schema'
import type { 
  ImpersonateCheckPayload,
  ImpersonateFetchTargetPayload,
  ImpersonateStartPayload,
  ImpersonateEndPayload,
  ImpersonationContext,
} from 'nuxt-aegis'

// Hook payload types
type AegisHookEvent = Omit<NuxtRequestEvent, 'context'> & { context: RequestEventContext }

interface ImpersonateCheckPayload {
  requester: BaseTokenClaims
  targetUserId: string
  reason?: string
  event: AegisHookEvent
  ip: string
  userAgent: string
}

interface ImpersonateFetchTargetPayload {
  requester: BaseTokenClaims
  targetUserId: string
  event: AegisHookEvent
}

interface ImpersonateStartPayload {
  requester: BaseTokenClaims
  targetUser: BaseTokenClaims
  reason?: string
  event: AegisHookEvent
  ip: string
  userAgent: string
  timestamp: Date
}

interface ImpersonateEndPayload {
  restoredUser: BaseTokenClaims
  impersonatedUser: BaseTokenClaims
  event: AegisHookEvent
  ip: string
  userAgent: string
  timestamp: Date
}

// Impersonation context in JWT
interface ImpersonationContext {
  originalUserSub: string
  originalUserLookupId: string
  originalUserEmail?: string
  originalUserName?: string
  impersonatedAt: string
  reason?: string
  originalClaims?: Record<string, unknown>
}

Complete Example ​

See the playground implementation for a complete working example with:

  • Hook implementations in server/plugins/aegis.ts
  • UI controls in app/pages/index.vue
  • Demo API route showing dual context in server/routes/api/admin/impersonate-demo.get.ts

Released under the MIT License.