Backend guides
Impersonation
Let admins safely impersonate a user for support, with the audit trail that records it.
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
| Step | Endpoint | Gate |
|---|---|---|
| Start | POST /api/v1/admin/users/{userId}/impersonate | Allow.AdminImpersonateUser |
| Refresh | POST /api/v1/auth/refresh (the normal one) | valid refresh cookie |
| Stop | POST /api/v1/admin/impersonation/stop | presence 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:
| Condition | Error |
|---|---|
| target is yourself | CannotImpersonateSelf |
| target not found | UserNotFound |
| target is an admin | CannotImpersonateAdmin |
| target disabled or deleted | CannotImpersonateDisabledUser |
Stop called without an act claim | NotImpersonating |
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:
| Event | Action | Reason |
|---|---|---|
ImpersonationStartedEvent | Admin.ImpersonationStarted | the admin’s free-text reason |
ImpersonationEndedEvent | Admin.ImpersonationEnded | n/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.ImpersonateUserand is not already impersonating. It is disabled with a localized hint when the target is self, an admin, or disabled. ImpersonateUserDialog.tsxcollects the required reason via React Hook Form plus Zod, posts to Start, invalidates themequery, and navigates home.ImpersonationBanner.tsxrenders a sticky destructive bar wheneverme.impersonatoris 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(default30): rolling lifetime for both the access token and the impersonation refresh token, applied at Start and re-applied on every rotation.ImpersonationAbsoluteMinutes(default60): 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.ImpersonateUserand the start endpoint is reachable. - Start issues a session whose access token carries
actand is capped atImpersonationMinutes. - A refresh keeps the
actclaim and theImpersonatedByUserIdcolumn, with the shortened expiry. - An action performed as the target shows
Actor.UserId = targetandActor.OnBehalfOfUserId = adminin the audit log. -
Admin.ImpersonationStartedandAdmin.ImpersonationEndedevents bracket the session. - Stop restores the admin’s normal session and
GET /meno longer returns animpersonator. - Each guard (self, admin, disabled target) surfaces a localized error.