Articles

How to find disabled users who still hold Microsoft 365 licenses

A disabled account cannot sign in, but its license is still billed every month. Microsoft does not release a license when you block sign-in, and the admin center does not warn you that a blocked user is still licensed.

How does it happen?

Usually in one of three ways:

  1. Offboarding stops at the block. Someone leaves, the account is disabled the same day, and the step that removes the license is left for later, then forgotten.
  2. A shared-mailbox conversion keeps the license. The mailbox is converted so the team can read it, which is right, but the license that it no longer needs stays on.
  3. A litigation hold. Legal asks for the mailbox to be kept, and the license is left in place because nobody is sure whether removing it is safe.

The third is often a deliberate decision. The first two are waste.

How do you list them with Graph PowerShell?

You need Microsoft.Graph.Users and Microsoft.Graph.Identity.DirectoryManagement, and for the mailbox columns ExchangeOnlineManagement. The Graph scopes are read-only: User.Read.All for the users and Organization.Read.All for the subscription names.

# Written for the Microsoft Graph PowerShell SDK v2 and ExchangeOnlineManagement v3. Run it first against a test tenant.
Connect-MgGraph -Scopes "User.Read.All","Organization.Read.All" -NoWelcome

$skuName = @{}
Get-MgSubscribedSku | ForEach-Object { $skuName[$_.SkuId] = $_.SkuPartNumber }

$props = 'id','displayName','userPrincipalName','accountEnabled','assignedLicenses','licenseAssignmentStates'
$users = Get-MgUser -All -Filter "accountEnabled eq false" -Property $props |
    Where-Object { $_.AssignedLicenses.Count -gt 0 }

Connect-ExchangeOnline -ShowBanner:$false
$rows = foreach ($u in $users) {
    $mbx = Get-Mailbox -Identity $u.UserPrincipalName -ErrorAction SilentlyContinue
    [pscustomobject]@{
        User           = $u.UserPrincipalName
        Name           = $u.DisplayName
        Licenses       = ($u.AssignedLicenses.SkuId | ForEach-Object { $skuName[$_] }) -join '; '
        ViaGroup       = (@($u.LicenseAssignmentStates | Where-Object AssignedByGroup |
                            ForEach-Object { $skuName[$_.SkuId] }) | Select-Object -Unique) -join '; '
        MailboxType    = if ($mbx) { $mbx.RecipientTypeDetails } else { 'None' }
        Size           = if ($mbx) { (Get-MailboxStatistics -Identity $mbx.PrimarySmtpAddress).TotalItemSize } else { '' }
        Archive        = if ($mbx) { $mbx.ArchiveStatus } else { '' }
        LitigationHold = if ($mbx) { $mbx.LitigationHoldEnabled } else { '' }
        Holds          = if ($mbx) { $mbx.InPlaceHolds -join ';' } else { '' }
    }
}

$rows | Sort-Object MailboxType, User | Format-Table User, Licenses, MailboxType, Size, LitigationHold -AutoSize
$rows | Export-Csv .\disabled-licensed.csv -NoTypeInformation -Encoding UTF8

What the columns tell you:

  • Licenses shows part numbers such as SPB or SPE_E3, not product names. Microsoft publishes the mapping in its "Product names and service plan identifiers for licensing" list.
  • ViaGroup names the licenses that come from a group. Removing those from the user directly does not work; take the user out of the group.
  • MailboxType separates SharedMailbox rows from UserMailbox rows. A shared mailbox is created with sign-in blocked, so a licensed shared mailbox lands here too.

To put a monthly figure on each row, multiply by the list prices in How to find unused Microsoft 365 licenses, which also covers the other kinds of license waste.

What if you will not run a script?

In the Microsoft 365 admin center, go to Users, Active users. Open the filter menu and choose Sign-in blocked. Use Choose columns to show Licenses if it is not already there; every row with something in that column is a candidate. Export users gives you the same list as a CSV.

The admin center does not show the mailbox type, size or holds on that page. For a shared mailbox, check it under Teams & groups, Shared mailboxes, or in the Exchange admin center under Recipients, Mailboxes.

What do you do with each row?

One of three things, and write down which:

Release the license. For a person who has left and whose mail nobody needs, remove the license. If the mailbox might still be wanted, convert it first: once a user mailbox has no license, Exchange Online keeps its data for 30 days and then deletes it. In PowerShell, Set-MgUserLicense -UserId <upn> -AddLicenses @() -RemoveLicenses @('<sku id>') does it. The cmdlet is in Microsoft.Graph.Users.Actions and needs LicenseAssignment.ReadWrite.All, or User.ReadWrite.All, added to the scopes of Connect-MgGraph.

Convert to a shared mailbox. If the team still reads the mail, convert the mailbox to shared, then remove the license. Microsoft's rule: a shared mailbox needs no license while it stays under 50 GB with no archive and no litigation hold. Check the Size, Archive and LitigationHold columns before you remove it. A shared mailbox over 50 GB, or with an archive or a hold, keeps a license.

Keep it for a hold, and record why. If legal has the mailbox on litigation hold or an in-place hold, keep the license and note the hold, the matter, who asked and the date to review it. A row with a reason is a decision; a row without one comes back as a question next month. Ask your compliance team whether an inactive mailbox would meet the hold instead, since that path does not keep a license on a disabled account.

How do you stop it recurring?

Two habits catch most of it:

  • One line on the offboarding checklist: "Mailbox converted or released, license removed, or hold recorded." Put it next to the step that blocks sign-in, so both happen together. The same offboarding gap leaves app registrations with no owner and secrets nobody will renew; How to find expiring app registration secrets covers that side.
  • A monthly run of the script, compared with last month's CSV. New rows are offboardings that stopped halfway; rows that stay are either recorded holds or someone's unfinished work.

Would you rather not run it every month?

If you would rather not run this every month, GlacierPoint Ledger lists these users in its third check, disabled users still licensed, with the monthly cost of each account.

Checked against

  • Microsoft.Graph.Users 2.41.2 (Get-MgUser with -All, -Property and -Filter "accountEnabled eq false", a default filter that needs no -ConsistencyLevel; AssignedLicenses.SkuId, LicenseAssignmentStates.AssignedByGroup, LicenseAssignmentStates.SkuId, UserPrincipalName, DisplayName)
  • Microsoft.Graph.Identity.DirectoryManagement 2.41.2 (Get-MgSubscribedSku with SkuId and SkuPartNumber)
  • Microsoft.Graph.Users.Actions 2.41.2 (Set-MgUserLicense with -UserId, -AddLicenses, -RemoveLicenses; scope LicenseAssignment.ReadWrite.All or User.ReadWrite.All)
  • Microsoft.Graph.Authentication 2.41.2 (Connect-MgGraph -Scopes)
  • ExchangeOnlineManagement 3.10.1 (Connect-ExchangeOnline, Get-Mailbox with RecipientTypeDetails, ArchiveStatus, LitigationHoldEnabled, InPlaceHolds, PrimarySmtpAddress; Get-MailboxStatistics with TotalItemSize)
  • Module versions current on the PowerShell Gallery on 2026-10-16: Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.Users.Actions and Microsoft.Graph.Identity.DirectoryManagement 2.41.2; ExchangeOnlineManagement 3.10.1
  • Microsoft 365 admin center paths, checked by name: Users, Active users, the Sign-in blocked filter, Choose columns, Export users; Teams & groups, Shared mailboxes; Exchange admin center Recipients, Mailboxes
  • Shared mailbox licensing as Microsoft states it in "About shared mailboxes in Microsoft 365": no license up to 50 GB; Exchange Online Plan 2, or Plan 1 with Exchange Online Archiving, for more than 50 GB, an archive or a litigation hold; mailbox data kept 30 days after the license is removed, as Microsoft states in "Assign or unassign licenses for users in the Microsoft 365 admin center"
  • The cmdlets and property names were checked by name against the Microsoft Graph PowerShell SDK v2 reference and the Exchange Online cmdlet reference; the block was not run against a tenant by us: run it against a test tenant first