Articles

How to find unused Microsoft 365 licenses with PowerShell and Graph

Licenses outlive the people. A license is assigned the day someone starts and rarely released the day they leave, and the admin center shows totals, not which seats are paid for and unused.

Where does the waste hide?

In four places, and each needs a different read:

  1. Unassigned licenses on a subscription: bought, never given to anyone.
  2. Disabled or stale users who still hold a license.
  3. Overlapping plans on one person, such as Office 365 E3 beside Microsoft 365 E3, or Exchange Online Plan 1 beside Business Premium.
  4. Shared mailboxes holding a license they do not need.

How do you read all four with PowerShell?

You need the Microsoft Graph PowerShell SDK (Microsoft.Graph.Users and Microsoft.Graph.Identity.DirectoryManagement) and, for the last check, ExchangeOnlineManagement. The scopes are all read-only. AuditLog.Read.All is only for sign-in dates, which Microsoft records only in tenants with Entra ID P1 or P2. Without P1, asking for signInActivity fails; drop it from $props and use the Microsoft 365 usage reports for activity instead.

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

# 1. Unassigned licenses per subscription
$skus = Get-MgSubscribedSku
$skuName = @{}; foreach ($s in $skus) { $skuName[$s.SkuId] = $s.SkuPartNumber }
$skus | Where-Object CapabilityStatus -eq 'Enabled' |
    Select-Object SkuPartNumber, @{n='Enabled';e={$_.PrepaidUnits.Enabled}}, ConsumedUnits,
        @{n='Unassigned';e={$_.PrepaidUnits.Enabled - $_.ConsumedUnits}} |
    Where-Object Unassigned -gt 0 | Export-Csv .\unassigned.csv -NoTypeInformation

# 2. Disabled, stale and never-used licensed users
$props = 'id','userPrincipalName','accountEnabled','assignedLicenses','signInActivity','createdDateTime'
$users = Get-MgUser -All -Property $props | Where-Object { $_.AssignedLicenses.Count -gt 0 }
$stale = (Get-Date).AddDays(-90); $new = (Get-Date).AddDays(-30)
$idle = foreach ($u in $users) {
    $last = @($u.SignInActivity.LastSignInDateTime, $u.SignInActivity.LastNonInteractiveSignInDateTime) |
        Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1
    $why = if ($u.AccountEnabled -eq $false) { 'Disabled' }
           elseif ($last -and $last -lt $stale) { 'Stale' }
           elseif (-not $last -and $u.CreatedDateTime -lt $new) { 'Never signed in' }
    if ($why) { [pscustomobject]@{ User = $u.UserPrincipalName; Reason = $why; LastSignIn = $last
                Licenses = ($u.AssignedLicenses.SkuId | ForEach-Object { $skuName[$_] }) -join '; ' } }
}
$idle | Export-Csv .\idle-users.csv -NoTypeInformation

# 3. Overlap: a second SKU whose user plans are all inside the first
$plans = @{}
foreach ($s in $skus) { $plans[$s.SkuId] = @($s.ServicePlans | Where-Object AppliesTo -eq 'User' | ForEach-Object ServicePlanId) }
$overlap = foreach ($u in $users | Where-Object { $_.AssignedLicenses.Count -ge 2 }) {
    foreach ($a in $u.AssignedLicenses.SkuId) { foreach ($b in $u.AssignedLicenses.SkuId) {
        if ($a -eq $b -or -not $plans[$b]) { continue }
        if (-not ($plans[$b] | Where-Object { $_ -notin $plans[$a] })) {
            [pscustomobject]@{ User = $u.UserPrincipalName; Keep = $skuName[$a]; Drop = $skuName[$b] } }
            # a pair with identical plans appears twice: keep either
    } }
}
$overlap | Export-Csv .\overlap.csv -NoTypeInformation

# 4. Shared, room and equipment mailboxes that hold a license
Connect-ExchangeOnline -ShowBanner:$false
$licensed = @{}; foreach ($u in $users) { $licensed[$u.Id] = $u }
Get-Mailbox -ResultSize Unlimited -RecipientTypeDetails SharedMailbox,RoomMailbox,EquipmentMailbox |
    Where-Object { $licensed.ContainsKey($_.ExternalDirectoryObjectId) } | ForEach-Object {
        [pscustomobject]@{ Mailbox = $_.PrimarySmtpAddress; Type = $_.RecipientTypeDetails
            Size = (Get-MailboxStatistics -Identity $_.PrimarySmtpAddress).TotalItemSize
            Archive = $_.ArchiveStatus; LitigationHold = $_.LitigationHoldEnabled; Holds = $_.InPlaceHolds -join ';' }
    } | Export-Csv .\shared-licensed.csv -NoTypeInformation

A few things the output will not tell you on its own:

  • Free and trial SKUs show thousands of enabled units. Ignore FLOW_FREE, POWER_BI_STANDARD and similar in the unassigned list.
  • Shared mailboxes appear twice. Their accounts are disabled, so they also land in the disabled list. Handle them under check 4.
  • Business Premium plus E3 is not a strict overlap. Each has service plans the other lacks (30 shared of 55 and 51), so check 3 will not flag it. The same goes for E3 beside E5. Ledger flags strict containment, such as Office 365 E3 inside Microsoft 365 E3 or Exchange Online Plan 1 inside Business Premium, as priced waste, and lists near matches such as Business Premium plus Microsoft 365 E3 for review without a dollar figure. If you want those pairs reviewed by hand, search for them by name: $users | Where-Object { $n = $_.AssignedLicenses.SkuId | ForEach-Object { $skuName[$_] }; 'SPB' -in $n -and ($n -match 'SPE_E3|ENTERPRISEPACK') }.
  • Same-plan pairs are reported in both directions. Keep the cheaper one out of the result by hand.

How much is it costing?

Multiply each row by the monthly price of the license. Unassigned units cost their price times the count; a user costs the sum of their licenses; an overlap costs the SKU you drop. Microsoft's published US list prices per user per month, on an annual commitment, as of October 2026:

SKUPart numberList price a month
Microsoft 365 Business BasicO365_BUSINESS_ESSENTIALS$7.00
Microsoft 365 Business StandardO365_BUSINESS_PREMIUM$14.00
Microsoft 365 Business PremiumSPB$22.00
Office 365 E3ENTERPRISEPACK$26.00
Microsoft 365 E3SPE_E3$39.00
Microsoft 365 E5SPE_E5$60.00
Microsoft 365 F3SPE_F1$10.00
Exchange Online (Plan 1)EXCHANGESTANDARD$4.00

These are list prices, and Microsoft changes them; the July 2026 increase moved most of this table. A CSP partner or an enterprise agreement will price differently, so use your own invoice when you present the figure to someone who signs the bill.

What do you do with each list?

Unassigned. Reduce the seat count at the next renewal, or sooner if the subscription allows it. Check the subscription's commitment terms in Billing, Your products before you cut.

Disabled and stale users. Confirm the person has left, then convert the mailbox to a shared mailbox if anyone still needs it, and remove the license. Stale accounts on enabled users are a security question as well as a cost one; disable them before you remove the license. Offboarding usually leaves app secrets with no owner too; How to find expiring app registration secrets covers that side.

Overlaps. Remove the subset license from the user. If licenses come from groups, remove the user from the group instead, or the license returns.

Shared mailboxes. A shared, room or equipment mailbox needs no license while it stays under 50 GB with no archive and no hold. Rows with an archive, a litigation hold, an in-place hold or a size over 50 GB keep their license; release the rest.

Run it again monthly, after offboarding catches up, and compare the CSVs.

Would you rather not run it every month?

If you would rather not run this every month, GlacierPoint Ledger runs eleven checks like these, prices each one at list price or your own, and gives you a CSV per check.

Checked against

  • Microsoft.Graph.Users 2.x and Microsoft.Graph.Identity.DirectoryManagement 2.x (Get-MgUser with AssignedLicenses, AccountEnabled, SignInActivity.LastSignInDateTime, SignInActivity.LastNonInteractiveSignInDateTime, CreatedDateTime; Get-MgSubscribedSku with SkuId, SkuPartNumber, CapabilityStatus, ConsumedUnits, PrepaidUnits.Enabled, ServicePlans.ServicePlanId, ServicePlans.AppliesTo)
  • Microsoft.Graph.Authentication 2.x (Connect-MgGraph -Scopes)
  • ExchangeOnlineManagement 3.10.1 (Connect-ExchangeOnline, Get-Mailbox, Get-MailboxStatistics)
  • Prices: Microsoft's US list prices as of October 2026, matching Ledger's price table version 4 (as of 2026-10-11)
  • Module versions current on the PowerShell Gallery on 2026-10-14: Microsoft.Graph.Authentication, Microsoft.Graph.Users and Microsoft.Graph.Identity.DirectoryManagement 2.41.2; ExchangeOnlineManagement 3.10.1
  • 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
  • Sign-in activity (signInActivity) is returned only for tenants with Microsoft Entra ID P1 or P2; without it the stale and never-signed-in lists are empty, and the usage reports (Reports.Read.All) are the fallback