Most Microsoft 365 tenants pay for licenses nobody uses. The usual causes:
- Someone leaves, their account gets disabled, but the license stays assigned.
- An account is created for a hire or a project and never signed into.
- A user stops using the account, and it sits inactive for months.
Each one keeps billing every month. Here's how to find them with Microsoft Graph PowerShell, read-only.
What you need
- PowerShell 7+
- The Graph module:
Install-Module Microsoft.Graph -Scope CurrentUser - An admin who can consent to
User.Read.All,AuditLog.Read.AllandOrganization.Read.All -
Entra ID P1 or higher for sign-in data (Business Premium and E3/E5 include it). Without it,
signInActivitycomes back empty.
Step 1: Connect
Connect-MgGraph -Scopes "User.Read.All","AuditLog.Read.All","Organization.Read.All" -NoWelcome
AuditLog.Read.All is the one people miss. Without it you can't read last sign-in dates.
Step 2: Map license IDs to names
Users' licenses come back as GUIDs. Turn them into readable SKU names:
$skuMap = @{}
Get-MgSubscribedSku | ForEach-Object { $skuMap[$_.SkuId] = $_.SkuPartNumber }
SPB is Business Premium, O365_BUSINESS_PREMIUM is Business Standard, SPE_E3 is Microsoft 365 E3, and so on.
Step 3: Pull users with the right properties
signInActivity isn't returned by default. Ask for it explicitly:
$users = Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,AccountEnabled,AssignedLicenses,SignInActivity,CreatedDateTime
Step 4: Decide what counts as waste
For each licensed user:
$cutoff = (Get-Date).AddDays(-90) # inactive threshold
$graceCutoff = (Get-Date).AddDays(-30) # don't flag brand-new accounts
$last = $u.SignInActivity.LastSignInDateTime
$reason = if (-not $u.AccountEnabled) { 'Disabled but licensed' }
elseif (-not $last -and $u.CreatedDateTime -lt $graceCutoff) { 'Never signed in' }
elseif ($last -and $last -lt $cutoff) { 'Inactive 90+ days' }
Two lessons from testing:
- Use a grace period. Sign-in data can lag by hours, and a user created yesterday has "never signed in" for a good reason. Without the grace period, every new hire shows up as waste.
- Disabled comes first. A disabled account with a license is waste regardless of when it last signed in.
Step 5: Put a price on it
A count of flagged users is easy to ignore. A monthly dollar figure isn't. Keep a small price table (approximate US list prices; edit to what you actually pay):
$Prices = @{ 'SPB' = 22.00; 'O365_BUSINESS_PREMIUM' = 12.50; 'SPE_E3' = 36.00 }
Then sum the flagged licenses and multiply by 12 for the yearly number.
Example result
From a test tenant with two disabled-but-licensed users:
User Reason License MonthlyCost
---- ------ ------- -----------
Casey Morgan Disabled but licensed SPB 22.00
Taylor Brooks Disabled but licensed SPB 22.00
Flagged licenses: 2 Estimated waste: $44.00/month ($528.00/year)
The full script
I put all of this into one free, MIT-licensed, read-only script that also exports a CSV:
- GitHub: https://github.com/Saiasanc/m365-license-waste
- PowerShell Gallery:
Install-Script Find-M365LicenseWaste -Scope CurrentUser
Run it with defaults, or tune the thresholds:
Find-M365LicenseWaste.ps1 -InactiveDays 60 -GraceDays 14 -OutFile .\report.csv
I'm the author. I'm also working on a multi-tenant version for MSPs; there's a waitlist link in the repo if that's useful to you. Feedback on what it should flag is welcome in the comments.
Top comments (0)