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 });
}
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.