Skip to content
Slicekit

Backend guides

Impersonation

Let admins safely impersonate a user for support, with the audit trail that records it.

View .md
On this page

What impersonation gives you

A holder of Admin.ImpersonateUser can act in the app as another user for a short, capped window, while every action they take is still attributed to them in the audit log. It is support tooling: reproduce a bug a customer reports, check what they actually see, fix it, and step back out, all without ever knowing their password.

The trick is a layered JWT. The target user drives the session (permissions, GET /me, everything the app reads from sub), while the acting admin rides along in an extra act claim that exists only for audit attribution and the stop gate.

sub = target user   // drives permissions, JWT validation, GetMe
act = admin user    // audit attribution + "stop impersonating" gate

Because sub is the target, the rest of the system needs zero impersonation awareness. The permission cache loads the target’s permissions, and the usual !Enabled short-circuit still kills the session instantly if the target is disabled mid-flight. See Authentication for how the access/refresh token pair works in the normal case.

The three endpoints

StepEndpointGate
StartPOST /api/v1/admin/users/{userId}/impersonateAllow.AdminImpersonateUser
RefreshPOST /api/v1/auth/refresh (the normal one)valid refresh cookie
StopPOST /api/v1/admin/impersonation/stoppresence of the act claim

Start

The endpoint at api/src/Slicekit.Api/Endpoints/v1/Admin/StartImpersonationEndpoint.cs requires a free-text reason (recorded in the audit log), runs the guards, revokes the admin’s current session, and issues a fresh one whose access token carries act = adminId and is capped at Auth:ImpersonationMinutes:

var adminId = principal.TryGetUserId() ?? throw new UnauthorizedAccessException();
var (rawRefresh, refreshHash) = tokenService.GenerateRefreshToken();

var result = await bus.InvokeAsync<Result<StartImpersonationResult>>(
    new StartImpersonationCommand(adminId, adminSessionId, userId, refreshHash, request.Reason, deviceName), ct);
if (!result.IsSuccess) return result.Error.ToProblem();

var lifetime = TimeSpan.FromMinutes(authOptions.Value.ImpersonationMinutes);
var accessToken = tokenService.GenerateAccessToken(
    userId, result.Value.SessionId, result.Value.TargetPermissions,
    actingUserId: adminId, lifetime: lifetime);
CookieHelper.SetAuthCookies(response, accessToken, rawRefresh, lifetime, lifetime, CookieHelper.IsSecure(httpContext));

The handler (api/src/Slicekit.Core/Features/Admin/StartImpersonation/Handler.cs) creates a new RefreshToken row with ImpersonatedByUserId = adminId and raises ImpersonationStartedEvent. That nullable column is the only schema change impersonation needs, and it exists for one reason: to survive token rotation.

How the session carries it across a refresh

Access tokens are short-lived, so the client will hit POST /api/v1/auth/refresh long before a 30-minute impersonation window closes. The standard refresh path keeps the impersonation alive: the rotate-session handler copies ImpersonatedByUserId onto the new row, caps ExpiresAtUtc at now + ImpersonationMinutes (not the usual 30 days), and hands the acting id back so the new JWT keeps its act claim. Nothing in the refresh endpoint is impersonation-specific beyond threading that value through. This is why the DB column matters: the act claim alone would be lost on the next rotation.

Stop

api/src/Slicekit.Api/Endpoints/v1/Admin/StopImpersonationEndpoint.cs is deliberately gated on the claim, not a permission:

if (!principal.IsImpersonating) return AdminErrors.NotImpersonating.ToProblem();

var actingUserId = principal.TryGetActingUserId() ?? throw new UnauthorizedAccessException();
// ... ends the impersonation row, issues a fresh NORMAL session for the admin

The acting admin may not hold Admin.ImpersonateUser through the target’s permission set, so requiring the permission here would trap them inside the session. The precondition is simply: do you carry an act claim? IsImpersonating and TryGetActingUserId live in api/src/Slicekit.Api/Auth/ClaimsPrincipalExtensions.cs. Stop ends the impersonation row (raising ImpersonationEndedEvent), then issues a fresh normal-lifetime session for the admin.

The guards

All of these run in the start handler and return a ForbiddenError that maps to a localized toast on the SPA:

ConditionError
target is yourselfCannotImpersonateSelf
target not foundUserNotFound
target is an adminCannotImpersonateAdmin
target disabled or deletedCannotImpersonateDisabledUser
Stop called without an act claimNotImpersonating

Two admins can impersonate the same user at once: each gets its own session family. Add a pre-flight check in the start handler if you want exclusivity.

The audit trail

This is the whole point. AuditService.EnrichActor (api/src/Slicekit.Core/Auditing/AuditService.cs) reads the act claim and stamps AuditActor.OnBehalfOfUserId onto every audit event raised during the session. So an unrelated action the admin performs as the target (a profile update, an API-key creation) carries both ids, and no individual emitter has to know impersonation is happening.

On top of that ambient attribution, two dedicated events bracket the session:

EventActionReason
ImpersonationStartedEventAdmin.ImpersonationStartedthe admin’s free-text reason
ImpersonationEndedEventAdmin.ImpersonationEndedn/a

Both are pinned to the audit hash chain like every other security event. See Auditing for how actor attribution and the tamper-evident chain work.

Frontend surface

  • The admin user-detail page shows an Impersonate action when the caller holds Admin.ImpersonateUser and is not already impersonating. It is disabled with a localized hint when the target is self, an admin, or disabled.
  • ImpersonateUserDialog.tsx collects the required reason via React Hook Form plus Zod, posts to Start, invalidates the me query, and navigates home.
  • ImpersonationBanner.tsx renders a sticky destructive bar whenever me.impersonator is set, with a Stop button:
if (!me?.impersonator) return null;
// ... renders the banner with me.impersonator.email and a Stop button

GET /api/v1/users/me returns an impersonator: { id, email } | null built from the JWT act claim (threaded through GetMeQuery via principal.TryGetActingUserId()), not from the database. See Adding a permission for how the gating permission is declared and checked.

Settings

AuthSettings (appsettings.json under Auth:*):

  • ImpersonationMinutes (default 30): rolling lifetime for both the access token and the impersonation refresh token, applied at Start and re-applied on every rotation.
  • ImpersonationAbsoluteMinutes (default 60): hard cap on the refresh-token family. Past this, the admin must Stop and Start again.

Set production values with env-var overrides (Auth__ImpersonationMinutes=...); do not edit appsettings.json for environment-specific values.

Checklist

  • The caller holds Admin.ImpersonateUser and the start endpoint is reachable.
  • Start issues a session whose access token carries act and is capped at ImpersonationMinutes.
  • A refresh keeps the act claim and the ImpersonatedByUserId column, with the shortened expiry.
  • An action performed as the target shows Actor.UserId = target and Actor.OnBehalfOfUserId = admin in the audit log.
  • Admin.ImpersonationStarted and Admin.ImpersonationEnded events bracket the session.
  • Stop restores the admin’s normal session and GET /me no longer returns an impersonator.
  • Each guard (self, admin, disabled target) surfaces a localized error.