fullstackhero/dotnet-starter-kit · warning · CustomException

end current impersonation before starting a new one

Error message

end current impersonation before starting a new one

What it means

Impersonation sessions cannot nest. If the caller's claims already contain an ActorSubject claim — meaning they are currently impersonating someone — the handler throws CustomException('end current impersonation before starting a new one', BadRequest).

Solutions

  1. Call the end-impersonation endpoint first, then start the new session
  2. Use the original (pre-impersonation) token when starting a new impersonation
  3. Make the client guard: only show 'start impersonation' when not currently impersonating

Example fix

// before
await api.startImpersonation(target); // 400 while impersonating
// after
if (isImpersonating) await api.endImpersonation();
await api.startImpersonation(target);
Defensive patterns

Strategy: validation

Validate before calling

if (claims.some(c => c.type === 'actor_subject' || c.type === 'ActorSubject')) {
  throw new Error('already impersonating; end the current session first');
}

Type guard

const isImpersonating = (claims: {type: string}[]) => claims.some(c => c.type.toLowerCase().includes('actorsubject'));

Try / catch

try { await api.startImpersonation(req); }
catch (e) { if (e.status === 400 && /end current impersonation/.test(e.message)) { await api.endImpersonation(); return retryOnce(); } throw e; }

Prevention

When it happens

Trigger: Calling start-impersonation while already holding an active impersonation session (ActorSubject claim present in the JWT).

Common situations: A support agent forgets to end the current session and starts another; a SPA holding a stale impersonated token retries the start call; automation loops issuing start without stop.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/cc2d9a92bb740af3. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Identity/Modules.Identity/Features/v1/Impersonation/StartImpersonation/StartImpersonationCommandHandler.cs:85

            && !string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal))
        {
            throw new ForbiddenException("cross-tenant impersonation is restricted to platform operators");
        }

        // Prevent self-impersonation (pointless, confuses the audit trail). Caller error → explicit 4xx,
        // not the 500 CustomException defaults to.
        if (string.Equals(actorUserId, request.TargetUserId, StringComparison.Ordinal)
            && string.Equals(actorTenantId, request.TargetTenantId, StringComparison.Ordinal))
        {
            throw new CustomException("cannot impersonate yourself", errors: null, System.Net.HttpStatusCode.BadRequest);
        }

        // Prevent nesting: if the caller is already impersonating, require end-impersonation first.
        var callerClaims = _currentUser.GetUserClaims();
        if (callerClaims is not null
            && callerClaims.Any(c => c.Type == ClaimConstants.ActorSubject))
        {
            throw new CustomException(
                "end current impersonation before starting a new one",
                errors: null,
                System.Net.HttpStatusCode.BadRequest);
        }

        var targetClaimsResult = await _identityService
            .BuildClaimsForUserAsync(request.TargetUserId, request.TargetTenantId, cancellationToken);

        if (targetClaimsResult is null)
        {
            throw new NotFoundException("target user not found");
        }

        var (subject, claims) = targetClaimsResult.Value;
        var targetUserName = claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value
            ?? claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Name)?.Value;

        // Strip the auto-generated jti from BuildClaimsForUserAsync and inject our own, so the persisted

View on GitHub (pinned to 3f2959e683)