Admin user impersonation: implementation and audit requirements

Impersonation — support staff viewing the application as a specific customer would see it — is a legitimate support tool that is also one of the highest-risk capabilities in any SaaS system. Done carelessly, it creates an unaudited backdoor where support staff can access customer data with no accountability. Done correctly, it provides a controlled, fully audited window into customer context that helps resolve support issues while preserving the customer's ability to see exactly who accessed their account and when.

Token design for impersonation

The key design decision is whether to carry the impersonator identity in the JWT. There are two patterns:

Claim injection: Issue a token where sub is the target user but include an additional claim impersonated_by with the support staff's user ID. The token looks like a normal user token to downstream services, but the impersonation claim provides auditability.

// Claim injection impersonation token
{
  "sub": "user_TARGET",           // the user being impersonated
  "org": "org_CUSTOMER",
  "org_role": "member",
  "impersonated_by": "staff_ABC",  // the support staff member
  "impersonation_id": "imp_XYZ",  // unique ID for this impersonation session
  "impersonation_reason": "Support ticket #12345",
  "exp": 1631109600               // short expiry: 1 hour max
}

Nested JWT (token-in-token): The outer token identifies the support staff member. The inner token (carried as a claim) represents the context being accessed. This is more explicit and less likely to be confused with a real user token, but requires all services to understand the nested structure.

// Nested JWT approach
// Outer token: identifies the actor (support staff)
{
  "sub": "staff_ABC",
  "role": "platform_support",
  "context_token": "eyJhbGciOiJFUzI1NiJ9...", // inner token for the impersonated context
  "exp": 1631109600
}

// Inner context token (decoded from context_token):
{
  "sub": "user_TARGET",
  "org": "org_CUSTOMER",
  "context_type": "impersonation",
  "impersonation_id": "imp_XYZ",
}

For most SaaS applications, claim injection is the pragmatic choice. Nested JWTs require custom verification logic in every downstream service. Claim injection works with standard JWT verification libraries — services just need to be aware that impersonated_by signals an impersonation session and should log it accordingly.

Starting an impersonation session

// Admin panel: initiate impersonation
async function startImpersonation(staffUserId: string, targetUserId: string, reason: string) {
  // Verify the staff user has impersonation permission
  const staff = await getStaffUser(staffUserId);
  if (!staff.can_impersonate) throw new Error('Unauthorized');

  // Create an impersonation record
  const impersonationId = `imp_${crypto.randomUUID()}`;

  await db.query(`
    INSERT INTO impersonation_sessions
    (id, staff_user_id, target_user_id, reason, started_at, expires_at)
    VALUES ($1, $2, $3, $4, NOW(), NOW() + INTERVAL '1 hour')
  `, [impersonationId, staffUserId, targetUserId, reason]);

  // Log the impersonation start
  await recordAuditEvent({
    action: 'impersonation.started',
    actor_id: staffUserId,
    target_user_id: targetUserId,
    impersonation_id: impersonationId,
    reason,
  });

  // Optionally notify the target user (depends on your policy)
  // await notifyUserOfImpersonation(targetUserId, staffUserId);

  // Issue impersonation token
  const targetUser = await getUserById(targetUserId);
  const orgMembership = await getPrimaryOrgMembership(targetUserId);

  return issueImpersonationToken({
    targetUserId,
    orgId: orgMembership?.org_id,
    orgRole: orgMembership?.role,
    staffUserId,
    impersonationId,
    reason,
    expiresIn: '1h',
  });
}

Audit logging during impersonation

Every action taken during an impersonation session must be logged with the impersonation context. Simply logging the target user as the actor is not acceptable — the audit trail must distinguish actions taken by the user themselves from actions taken by support staff on their behalf.

// Middleware: extract impersonation context and augment audit logs
function auditMiddleware(req, res, next) {
  const token = parseToken(req);

  req.auditContext = {
    actor_id: token.sub,
    is_impersonated: !!token.impersonated_by,
    impersonated_by: token.impersonated_by,
    impersonation_id: token.impersonation_id,
    real_actor_id: token.impersonated_by || token.sub,  // who is really acting
  };

  next();
}

// Use in action handlers
async function updateUserSettings(req, res) {
  const settings = req.body;
  const userId = req.user.sub;

  await applySettings(userId, settings);

  // Log with full impersonation context
  await recordAuditEvent({
    action: 'user.settings.updated',
    actor_id: userId,
    actor_type: req.auditContext.is_impersonated ? 'impersonated_session' : 'user',
    impersonated_by: req.auditContext.impersonated_by,
    impersonation_id: req.auditContext.impersonation_id,
    changes: settings,
  });

  res.json({ success: true });
}
Some regulated industries (financial services, healthcare) require that customers be notified of impersonation events, either in real time or in a periodic report. Before implementing impersonation, check whether your industry or your customers' industries have specific requirements. Audit log visibility to the customer (a "who accessed my account" view) is increasingly expected in enterprise SaaS security reviews.

Time limits and auto-expiry

Impersonation sessions must have hard time limits. A support engineer should not leave an impersonation session open overnight. One hour is a common limit for active impersonation. Implement both token-level expiry (short JWT lifetime) and session-level expiry tracked in the database.

// Impersonation sessions table with hard expiry
CREATE TABLE impersonation_sessions (
  id             TEXT PRIMARY KEY,
  staff_user_id  UUID NOT NULL,
  target_user_id UUID NOT NULL,
  reason         TEXT NOT NULL,        -- linked support ticket or justification
  started_at     TIMESTAMPTZ DEFAULT NOW(),
  expires_at     TIMESTAMPTZ NOT NULL,
  ended_at       TIMESTAMPTZ,          -- explicit end by staff
  ended_reason   TEXT                  -- 'manual', 'expired', 'revoked'
);

-- Expired sessions cleanup job (run hourly)
UPDATE impersonation_sessions
SET ended_at = NOW(), ended_reason = 'expired'
WHERE expires_at < NOW() AND ended_at IS NULL;

UI indicators

The application UI must clearly indicate when a session is an impersonation. An administrator who is impersonating a customer must see a persistent banner — ideally with the target user's name and a prominent "End impersonation" button. This prevents support staff from accidentally performing actions they intended to perform in their own account context.

The banner should be impossible to dismiss without ending the session, rendered at the top of every page, and use a distinct visual style (typically a warning color, not the app's primary color). The "End impersonation" button should one-click: end the session, invalidate the impersonation token, and return the staff member to their own context.

For the customer's audit log view, impersonation events should be clearly labeled as "Accessed by support staff" with the staff member's name or role (depending on your privacy policy for staff identities), the timestamp, and the stated reason. Customers should be able to export this log. This is a standard expectation for enterprise security reviews.

← Back to blog Try Bastionary free →