Find-M365LicenseWaste.ps1
|
<#PSScriptInfo
.VERSION 0.3.0 .GUID 5f00d7f3-7f5d-49de-a222-cfe929396932 .AUTHOR Micky .COMPANYNAME .COPYRIGHT (c) 2026 Micky. MIT License. .TAGS Microsoft365 Office365 M365 PowerShell MSP License LicenseManagement MicrosoftGraph Entra CostSavings .LICENSEURI https://github.com/Saiasanc/m365-license-waste/blob/main/LICENSE .PROJECTURI https://github.com/Saiasanc/m365-license-waste .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES 0.3.0 - First PowerShell Gallery release. Grace period for new accounts; fixed totals line. .PRIVATEDATA #> #Requires -Version 7.0 #Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.Identity.DirectoryManagement <# .SYNOPSIS Finds wasted Microsoft 365 licenses and estimates their monthly cost. .DESCRIPTION Read-only scan of a Microsoft 365 tenant. Flags licensed users who are disabled, have never signed in (after a grace period for new accounts), or have been inactive for N+ days. Estimates monthly and yearly cost from approximate US list prices and exports the results to CSV. Never changes users, licenses or settings. .PARAMETER InactiveDays Days since last sign-in before a user counts as inactive. Default 90. .PARAMETER GraceDays New accounts younger than this are not flagged as "never signed in". Default 30. .PARAMETER OutFile Path of the CSV report. Default .\M365-License-Waste.csv .EXAMPLE .\Find-M365LicenseWaste.ps1 .EXAMPLE .\Find-M365LicenseWaste.ps1 -InactiveDays 60 -GraceDays 14 -OutFile .\report.csv #> param( [int]$InactiveDays = 90, [int]$GraceDays = 30, [string]$OutFile = ".\M365-License-Waste.csv" ) # Approximate US list prices per user per month (edit as needed) $Prices = @{ 'SPB' = 22.00 # Business Premium 'O365_BUSINESS_PREMIUM' = 12.50 # Business Standard 'O365_BUSINESS_ESSENTIALS' = 6.00 # Business Basic 'ENTERPRISEPACK' = 23.00 # Office 365 E3 'ENTERPRISEPREMIUM' = 38.00 # Office 365 E5 'SPE_E3' = 36.00 # Microsoft 365 E3 'SPE_E5' = 57.00 # Microsoft 365 E5 } Connect-MgGraph -Scopes "User.Read.All","AuditLog.Read.All","Organization.Read.All" -NoWelcome $skuMap = @{} Get-MgSubscribedSku | ForEach-Object { $skuMap[$_.SkuId] = $_.SkuPartNumber } $cutoff = (Get-Date).AddDays(-$InactiveDays) $graceCutoff = (Get-Date).AddDays(-$GraceDays) $users = Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,AccountEnabled,AssignedLicenses,SignInActivity,CreatedDateTime $results = foreach ($u in $users) { if (-not $u.AssignedLicenses) { continue } $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 $InactiveDays+ days" } else { $null } if (-not $reason) { continue } foreach ($lic in $u.AssignedLicenses) { $sku = $skuMap[$lic.SkuId] $cost = if ($Prices.ContainsKey($sku)) { $Prices[$sku] } else { 0 } [pscustomobject]@{ User = $u.DisplayName UPN = $u.UserPrincipalName Reason = $reason License = $sku LastSignIn = $last Created = $u.CreatedDateTime MonthlyCost = $cost } } } $results | Export-Csv $OutFile -NoTypeInformation $total = ($results | Measure-Object MonthlyCost -Sum).Sum $results | Format-Table User, Reason, License, MonthlyCost -AutoSize Write-Host ('Flagged licenses: {0} Estimated waste: ${1:N2}/month (${2:N2}/year)' -f @($results).Count, $total, ($total*12)) -ForegroundColor Yellow Write-Host "Saved to $OutFile" |