fullstackhero/dotnet-starter-kit · warning · UnauthorizedException

Access token subject mismatch.

Error message

Access token subject mismatch.

What it means

Handle throws UnauthorizedException("Access token subject mismatch.") when the optional access token supplied with the refresh request parses to a subject that does not match (ordinal comparison) the subject embedded in the refresh token — indicating the tokens belong to different users.

Solutions

  1. Store access+refresh token pairs keyed by user/session, never globally
  2. If the access token is expired or mismatched, send refresh without it (it is optional)
  3. Clear stored tokens and log in again

Example fix

// before
localStorage.setItem('auth', JSON.stringify(tokens));
// after
localStorage.setItem(`auth:${userId}`, JSON.stringify({ accessToken, refreshToken }));
Defensive patterns

Strategy: validation

Validate before calling

const sub = jwtDecode(accessToken)?.sub;
const rSub = jwtDecode(refreshToken)?.sub;
if (sub && rSub && sub !== rSub) { clearTokens(); redirectToLogin(); }

Type guard

function tokensMatchPair(accessToken: string, refreshToken: string): boolean {
  const a = jwtDecode<{ sub?: string }>(accessToken);
  const r = jwtDecode<{ sub?: string }>(refreshToken);
  return !a?.sub || !r?.sub || a.sub === r.sub;
}

Try / catch

try { return await refreshAsync(refreshToken, accessToken); }
catch (UnauthorizedException e) when (e.Message.Contains("mismatch")) { clearTokens(); redirectToLogin(); return null; }

Prevention

When it happens

Trigger: Client mixes up tokens across accounts (e.g. stores one global token bag for multiple logged-in users); stale access token from a previous login paired with a new refresh token; corrupted token storage.

Common situations: Multi-account apps swapping localStorage entries; token store not keyed per user; copy/paste of tokens between test users.

Related errors


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

Appendix: source

Thrown at src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs:90

        {
            parsedAccessToken = handler.ReadJwtToken(request.Token);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to parse access token during refresh; relying on refresh-token validation only");
        }

        if (parsedAccessToken is not null)
        {
            var accessTokenSubject = parsedAccessToken.Claims
                .FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value
                ?? parsedAccessToken.Subject;

            if (!string.IsNullOrEmpty(accessTokenSubject) &&
                !string.Equals(accessTokenSubject, subject, StringComparison.Ordinal))
            {
                await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenSubjectMismatch", cancellationToken);
                throw new UnauthorizedException("Access token subject mismatch.");
            }
        }

        // Audit previous token revocation by rotation (no raw tokens)
        await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenRotated", cancellationToken);

        // Issue new tokens
        var newToken = await _tokenService.IssueAsync(subject, claims, null, cancellationToken);

        // Persist rotated refresh token for this user
        await _identityService.StoreRefreshTokenAsync(subject, newToken.RefreshToken, newToken.RefreshTokenExpiresAt, cancellationToken);

        // Update the session with the new refresh token hash
        var newRefreshTokenHash = Sha256Short(newToken.RefreshToken);
        await _sessionService.UpdateSessionRefreshTokenAsync(
            refreshTokenHash,
            newRefreshTokenHash,
            newToken.RefreshTokenExpiresAt,

View on GitHub (pinned to 3f2959e683)