User impersonation for support: safe implementation without security theatre

Support teams need to reproduce issues that users report. Sometimes that means looking at a user's data, sometimes it means stepping through a flow that is broken for that specific account. "User impersonation" is the capability that lets a support engineer access the application from the user's perspective without knowing their password. Done poorly, impersonation creates a backdoor with no audit trail that any support employee can abuse to access any account at any time. Done well, it is a controlled, fully audited capability that is essential for effective support.

Why a separate token is non-negotiable

The first design decision: impersonation sessions must use a distinct token type, never a regular user session token. The impersonation token carries both the impersonator's identity and the target user's identity. This distinction must be visible in every audit log entry and must be checked before any action that should not be available during impersonation.

interface ImpersonationToken {
  iss: string;
  iat: number;
  exp: number;
  jti: string;
  // The support engineer who initiated impersonation
  actor_id: string;
  actor_email: string;
  // The user being impersonated
  sub: string;       // target user ID
  org: string;       // target org ID
  // Marks this as an impersonation session — never issue a normal token with this
  impersonation: true;
  // Ticket reference for audit purposes
  ticket_id?: string;
}

// Issue impersonation token
async function issueImpersonationToken(
  actorId: string,
  targetUserId: string,
  ticketId?: string
): Promise<string> {
  // Authorization check: actor must have impersonation permission
  const actor = await db.users.findById(actorId);
  if (!actor.permissions.includes('support:impersonate')) {
    throw new Error('impersonation_not_authorized');
  }

  const target = await db.users.findById(targetUserId);

  const payload: ImpersonationToken = {
    iss: 'https://auth.example.com',
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600,  // 1 hour max
    jti: crypto.randomUUID(),
    actor_id: actorId,
    actor_email: actor.email,
    sub: targetUserId,
    org: target.orgId,
    impersonation: true,
    ticket_id: ticketId,
  };

  // Log the impersonation start event
  await auditLog.record({
    event: 'impersonation.started',
    actorId,
    targetUserId,
    ticketId,
    ip: getCurrentRequestIP(),
    timestamp: new Date(),
  });

  return signJWT(payload, IMPERSONATION_SIGNING_KEY);
}

Audit logging every action

A single audit entry at impersonation start is not enough. Every meaningful action taken during an impersonation session should be attributed to the impersonating actor, not to the target user. This is critical for compliance and for investigating incidents: if data is modified during an impersonation session, the audit log must show that the modification was made by a support engineer, not by the user themselves.

// Middleware: extract actor context from impersonation token
function resolveRequestContext(req: Request): RequestContext {
  const token = extractBearerToken(req);
  const payload = verifyJWT(token);

  if (payload.impersonation) {
    return {
      userId: payload.sub,           // The user being impersonated (for data access)
      orgId: payload.org,
      isImpersonated: true,
      actorId: payload.actor_id,     // The real actor for audit purposes
      actorEmail: payload.actor_email,
    };
  }

  return {
    userId: payload.sub,
    orgId: payload.org,
    isImpersonated: false,
    actorId: payload.sub,
    actorEmail: null,
  };
}

// Audit log helper: always use actor, not userId
async function recordAuditEvent(ctx: RequestContext, event: string, metadata: object) {
  await db.auditEvents.create({
    event,
    userId: ctx.userId,          // Whose data was affected
    actorId: ctx.actorId,        // Who actually did it
    isImpersonated: ctx.isImpersonated,
    actorEmail: ctx.actorEmail,
    metadata,
    timestamp: new Date(),
    ip: getCurrentRequestIP(),
  });
}

Impersonation scope limitations

Even with full read access, certain actions should be blocked during impersonation. A support engineer should never be able to change a user's password, update billing information, link or unlink authentication methods, or modify MFA settings — even if the regular user can do these things. These capabilities represent durable security changes to the account that must only happen when the account owner authenticates directly.

// Block sensitive operations during impersonation
function blockDuringImpersonation(req: Request, res: Response, next: NextFunction) {
  const ctx = req.requestContext;
  if (ctx.isImpersonated) {
    return res.status(403).json({
      error: 'action_not_available_during_impersonation',
      message: 'This action requires the account owner to be directly authenticated.',
    });
  }
  next();
}

// Apply to sensitive routes
router.post('/account/change-password', blockDuringImpersonation, changePasswordHandler);
router.put('/account/mfa', blockDuringImpersonation, updateMFAHandler);
router.post('/account/link/:provider', blockDuringImpersonation, linkOAuthHandler);
router.delete('/billing/payment-method', blockDuringImpersonation, removePaymentHandler);
Notify the user when their account has been accessed via impersonation. A clear email — "A support engineer accessed your account on [date] in connection with ticket #[id]" — is both a transparency measure and a security control. Users can flag unauthorized impersonation events they were not expecting. Enterprise customers in regulated industries often require this for compliance.

The exit mechanism

The impersonation session must have a clear, obvious, accessible way to end. The support engineer's UI should display a persistent banner showing whose account they are viewing, how long the session has left, and a prominent exit button. The exit button invalidates the impersonation token and returns the engineer to their own admin session.

// End impersonation session
app.post('/admin/impersonation/end', requireAdminAuth, async (req, res) => {
  const { impersonationTokenJti } = req.body;

  // Revoke the impersonation token
  await redis.setex(`revoked_token:${impersonationTokenJti}`, 7200, '1');

  await auditLog.record({
    event: 'impersonation.ended',
    actorId: req.admin.id,
    jti: impersonationTokenJti,
    timestamp: new Date(),
  });

  res.json({ success: true });
});

RBAC guard

Impersonation is a sensitive capability that should be a named, grantable permission, not an implicit property of being a support employee. Grant the support:impersonate permission explicitly to specific users or roles, log every grant in the admin audit log, and review the list quarterly. Use time-limited grants where possible — a support engineer handling an escalation may need impersonation access for 30 days, not permanently.

Consider requiring approval for impersonation: the support engineer requests access to a specific user's account in a ticketing system, a supervisor approves the request, and the approval creates an impersonation grant that is valid for a specific time window. This two-person rule means no single support employee can unilaterally access any account.

← Back to blog Try Bastionary free →