How to Find Inactive (Stale) User Accounts

Gather Data About Inactive Accounts to Recover Unused Licenses

With increases for many Microsoft 365 licenses due on July 1, 2026, I hope that administrators are analyzing what licenses are used within their tenant and to whom the licenses are assigned. Getting value from Microsoft 365 licenses takes hard work and ongoing effort to make sure that budgets are not eroded by unnecessary expenditure for unneeded licenses. Hopefully, some of you find the Microsoft 365 Licensing Report script useful in that respect. I just updated the script to V1.96 to fix some bugs and add a few features, like highlighting room mailboxes with licenses.

Apart from calculating how much licenses assigned to individual users and departments cost annually, the licensing script highlights issues like license assigned to disabled accounts. Removing these licenses is an easy way to cut cost. The script also reports the date of last access to the tenant and how many days it’s been since user accounts last signed in (Figure 1).

Highlighting inactive user accounts in the Microsoft 365 Licensing Report.
Figure 1: Highlighting inactive user accounts in the Microsoft 365 Licensing Report

Once they know about stale accounts, administrators can review the assigned licenses to understand if licenses are still required by the inactive accounts.

For instance, an account might exist for someone who’s on a temporary sabbatical or parenting leave, which is why they haven’t signed in for more than 60 days. In these scenarios, to retain the user’s mailbox and other personal data, it might be possible to replace expensive high-end licenses like Microsoft 365 E5 or E7 with something less expensive and save $50 a month.

Another Way to Find Inactive Accounts

Running the Microsoft 365 Licensing Report script is certainly a great way to search for inactive accounts and the report provides all sorts of interesting data about licensing spend. However, sometimes a quick answer is all that’s needed. PowerShell is very flexible, so let’s discuss how to provide the answer using the Microsoft Graph PowerShell SDK.

First, sign in with an administrator account. Consent is required for the User.Read.All and Places.Read.All permissions to read user data, including licenses, and to read places (room mailboxes), to allow the script to highlight licensed rooms that might otherwise be detected as inactive users. You’ll also need the LicenseAssignment.Read.All permission to run the Get-MgSubscribedSKU cmdlet to find details of tenant subscriptions.

Now find licensed accounts to process. This command finds all licensed accounts. In large tenants, you might want to add another filter to focus on a smaller set. We’ll also find the room mailboxes (some room mailboxes might not show up with the API). If you sign the PowerShell session into Exchange Online, you can use the Get-Mailbox cmdlet to create an array of the account identifiers for room and shared mailboxes to use when checking accounts. I haven’t done that here because I wanted the code to only use one module (to avoid assembly clashes).

[array]$Users = Get-MgUser -Filter "assignedLicenses/`$count ne 0 and userType eq 'Member'" -Property Id, displayName, userPrincipalName, assignedLicenses, SignInSessionsValidFromDateTime, SignInactivity, AccountEnabled -ConsistencyLevel eventual -CountVariable Records -All -PageSize 500 | Sort-Object DisplayName

[array]$Places = Get-MgPlaceAsRoom -All -PageSize 500 | Sort-Object displayName

Next, find the subscriptions used in the tenant and create a hash table from the subscription identifier and name.

[array]$SKUs = Get-MgSubscribedSKU | Select-Object SkuId, SkuPartNumber
$SKUHash = @{}
ForEach ($SKU in $SKUs) {
  $SKUHash.Add($SKU.SkuId, $SKU.SkuPartNumber)
}

Searching for Inactive Accounts

The magic now comes together by searching for user accounts whose SignInSessionsValidFromDateTime property is more than 90 days ago. Entra ID updates this value to set a new baseline for refresh tokens when tokens are invalidated by being reset, revoked, or through a password change. Essentially, this means that any refresh token issued by Entra ID for the account after the baseline date can be used during the sign-in process. The script also checks that the account hasn’t signed in successfully since Entra ID last reset the token sign-in baseline. In other words, we’re looking for people with old refresh tokens who haven’t signed in since the last baseline was set.

$CutoffDate = (Get-Date).AddDays(-90).ToUniversalTime()

ForEach ($User in $Users) {
  If (($User.SignInSessionsValidFromDateTime) -and ($User.SignInSessionsValidFromDateTime -lt $CutoffDate)) -and 
  (($null -eq $User.SignInActivity.LastSuccessfulSignInDateTime) -or 
  ([datetime]$User.SignInActivity.LastSuccessfulSignInDateTime -le $User.SignInSessionsValidFromDateTime))) {   

    If ($Places.DisplayName -contains $User.DisplayName) {
      $UserType = "Room"
    } else {
      $UserType = "User"
    }
    # Resolve product SKUs to friendly names for output
    [array]$ProductFriendlyNames = $null
     ForEach ($License in $User.AssignedLicenses.SkuId) {
       If ($SKUHash.ContainsKey($License)) {
           $ProductFriendlyNames += $SKUHash[$License]
       } Else {
           $ProductFriendlyNames += "Unknown SKU (" + $License + ")"
       }
     }

    $ReportLine = [PSCustomObject][Ordered]@{
      UserPrincipalName   = $User.UserPrincipalName
      DisplayName         = $User.DisplayName
      SignInSessionsValidFromDateTime = if ($null -ne $User.SignInSessionsValidFromDateTime) { Get-Date $User.SignInSessionsValidFromDateTime -format "dd-MMM-yyyy HH:mm" } else { $null }
      SignInActivity      = If ($null -ne $User.SignInActivity.LastSuccessfulSignInDateTime) { Get-Date $User.SignInActivity.LastSuccessfulSignInDateTime -format "dd-MMM-yyyy HH:mm" } else { $null }
      DaysSinceLastSignIn = If ($null -ne $User.SignInActivity.LastSuccessfulSignInDateTime) { [int]((Get-Date) - (Get-Date $User.SignInActivity.LastSuccessfulSignInDateTime)).TotalDays } else { $null }    
      AccountEnabled      = $User.AccountEnabled
      UserType            = $UserType
      Licenses            = $ProductFriendlyNames -Join ", "
      SKUs                = $User.AssignedLicenses.SkuId
    }
    $Report.Add($ReportLine)
  }
}

Figure 2 shows the result when run on my tenant. Some of the accounts can be discounted (like a break glass account) and others (like room mailboxes) should be queried to validate that the correct licenses are assigned.

Listing possible inactive accounts.
Figure 2: Listing possible inactive accounts

The other accounts are stale. Their baseline for refresh tokens is more than 90 days old as is their last successful sign-in. These accounts are prime candidates for license removal or replacement.

Information is There, if You Look for It

I guess it’s not altogether surprising that the Microsoft 365 admin center doesn’t include a report to help administrators identify stale accounts so that licenses can be removed from those accounts. It’s in Microsoft’s business interests to keep licenses attached to accounts, whether or not the licenses are ever used. It’s in your interests to make sure that licenses are used with maximum efficiency, whatever that takes. You can download a full working version of the script used here from the Office 365 for IT Pros GitHub Repository.


Support the work of the Office 365 for IT Pros team by subscribing to the Office 365 for IT Pros eBook. Your support pays for the time we need to track, analyze, and document the changing world of Microsoft 365 and Office 365. Only humans contribute to our work!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.