fullstackhero/dotnet-starter-kit · error · UnauthorizedException

refresh token is invalid or expired

Error message

refresh token is invalid or expired

What it means

During token refresh, FindUserByRefreshTokenAsync hashes the presented refresh token and searches users by the stored RefreshToken hash. If no user matches, it throws UnauthorizedException("refresh token is invalid or expired") — a single generic message used both for unknown and expired tokens.

Solutions

  1. Fall back to full re-authentication: obtain a fresh token pair via the login endpoint.
  2. Ensure the client stores the LATEST refresh token returned by each refresh call (rotation invalidates the previous one).
  3. Verify the client is calling the same environment/database the token was issued from.

Example fix

// before: persist old token after refresh
localStorage.setItem('refreshToken', oldToken);
// after
localStorage.setItem('refreshToken', response.refreshToken); // newly rotated value
Defensive patterns

Strategy: fallback

Validate before calling

const canRefresh = !!auth.refreshToken && auth.refreshToken === auth.lastIssuedRefreshToken;
if (!canRefresh) return loginAgain();

Type guard

function hasRefreshToken(a: unknown): a is { refreshToken: string } {
  return typeof a === 'object' && a !== null && typeof (a as any).refreshToken === 'string' && (a as any).refreshToken.length > 0;
}

Try / catch

catch (ApiError e) when (e.StatusCode === 401 && e.Message.includes('refresh token')) {
  clearStoredTokens();
  redirectToLogin(); // fall back to interactive auth
}

Prevention

When it happens

Trigger: POST to the refresh endpoint with a token that was never issued, already rotated (old token superseded by a newer one), cleared from the user row, or issued to a different environment/database.

Common situations: Client kept a stale refresh token after a refresh rotation replaced it; database reseeded so stored hashes no longer match; two frontends sharing tokens across dev/staging databases; tokens stored client-side got truncated or re-encoded (URL-encoding issues).

Understand the failure class

Related errors


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

Appendix: source

Thrown at src/Modules/Identity/Modules.Identity/Services/IdentityService.cs:246

    private async Task<FshUser> FindUserByRefreshTokenAsync(string refreshToken, string tenantId, CancellationToken ct)
    {
        var hashedToken = HashToken(refreshToken);

        if (_logger.IsEnabled(LogLevel.Debug))
        {
            _logger.LogDebug(
                "Validating refresh token for tenant {TenantId}. Token hash: {TokenHash}",
                tenantId, hashedToken[..Math.Min(8, hashedToken.Length)]);
        }

        var user = await _userManager.Users
            .FirstOrDefaultAsync(u => u.RefreshToken == hashedToken, ct);

        if (user is null)
        {
            _logger.LogWarning("No user found with matching refresh token hash for tenant {TenantId}", tenantId);
            throw new UnauthorizedException("refresh token is invalid or expired");
        }

        return user;
    }

    private void ValidateRefreshTokenExpiry(FshUser user)
    {
        var now = _timeProvider.GetUtcNow().UtcDateTime;
        if (user.RefreshTokenExpiryTime <= now)
        {
            _logger.LogWarning(
                "Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}",
                user.Id, user.RefreshTokenExpiryTime, now);
            throw new UnauthorizedException("refresh token is invalid or expired");
        }
    }

    private static void ValidateUserStatus(FshUser user)

View on GitHub (pinned to 3f2959e683)