DEV Community

Micky
Micky

Posted on AI-assisted

Find wasted Microsoft 365 licenses with PowerShell and Microsoft Graph

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.All and Organization.Read.All
  • Entra ID P1 or higher for sign-in data (Business Premium and E3/E5 include it). Without it, signInActivity comes back empty.

Step 1: Connect

Connect-MgGraph -Scopes "User.Read.All","AuditLog.Read.All","Organization.Read.All" -NoWelcome
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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' }
Enter fullscreen mode Exit fullscreen mode

Two lessons from testing:

  1. 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.
  2. 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 }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The full script

I put all of this into one free, MIT-licensed, read-only script that also exports a CSV:

Run it with defaults, or tune the thresholds:

Find-M365LicenseWaste.ps1 -InactiveDays 60 -GraceDays 14 -OutFile .\report.csv
Enter fullscreen mode Exit fullscreen mode

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)