No recent activity does not automatically mean an unused license
A license may protect a retained mailbox, enable a rarely used app, support a leave of absence, room, shared device, or compliance requirement. Conversely, a disabled account can still consume several products. A defensible decision correlates the assignment, its source, available activity, owner, and business dependencies.
The ten collections below are read-only. They produce review candidates, not authorization to remove licenses. All identities and domains are fictional; use a protected administration workstation and controlled output folder.
Prepare Microsoft Graph with least-privileged permissions
User.Read.All reads users and their licenses, Organization.Read.All subscriptions, Group.Read.All groups, Reports.Read.All usage reports, and AuditLog.Read.All signInActivity. That last property also requires Microsoft Entra ID P1 or P2.
Install Microsoft.Graph modules under your workstation-management process. Start without LicenseAssignment.ReadWrite.All: none of the ten diagnostics needs it. The operator's Entra role must also authorize the reads.
$scopes = @(
'User.Read.All'
'Organization.Read.All'
'Group.Read.All'
'Reports.Read.All'
'AuditLog.Read.All'
)
Connect-MgGraph -Scopes $scopes -NoWelcome
Get-MgContext | Select-Object TenantId,Account,AuthType,Scopes1. Measure available capacity by SKU
SkuPartNumber is more readable than SkuId but is not a guaranteed commercial product name. Active, warning, and consumed units show pressure on a SKU; they do not identify who can safely lose it.
Get-MgSubscribedSku -All | ForEach-Object {
[pscustomobject]@{
SkuPartNumber = $_.SkuPartNumber
SkuId = $_.SkuId
Active = $_.PrepaidUnits.Enabled
Warning = $_.PrepaidUnits.Warning
Consumed = $_.ConsumedUnits
Available = $_.PrepaidUnits.Enabled - $_.PrepaidUnits.Warning - $_.ConsumedUnits
}
} | Sort-Object SkuPartNumber2. Inventory every licensed member
The advanced filter requires ConsistencyLevel eventual. Excluding guests reduces noise, but do not decide anything about synchronized, resource, or service identities without confirming their purpose.
$licensedUsers = Get-MgUser -All -Filter "assignedLicenses/$count ne 0 and userType eq 'Member'" -ConsistencyLevel eventual -Property Id,DisplayName,UserPrincipalName,AccountEnabled,UserType,AssignedLicenses
$licensedUsers | Select-Object DisplayName,UserPrincipalName,AccountEnabled,
@{n='LicenseCount';e={$_.AssignedLicenses.Count}}3. Isolate disabled accounts that remain licensed
This is a review queue, never an automatic cleanup list. Confirm departure, mailbox retention, OneDrive, eDiscovery, applications, devices, and delegation.
$disabledLicensed = $licensedUsers |
Where-Object AccountEnabled -eq $false |
Select-Object Id,DisplayName,UserPrincipalName,
@{n='LicenseCount';e={$_.AssignedLicenses.Count}}
$disabledLicensed | Sort-Object UserPrincipalName4. Translate SKU IDs into useful names
This join supports a discussion about a product instead of a GUID. One capability can come from several SKUs; do not count a feature as multiple recoverable licenses.
$skuMap = @{}
Get-MgSubscribedSku -All | ForEach-Object { $skuMap[$_.SkuId.Guid] = $_.SkuPartNumber }
$licenseInventory = foreach ($user in $licensedUsers) {
foreach ($license in $user.AssignedLicenses) {
[pscustomobject]@{
UserPrincipalName = $user.UserPrincipalName
AccountEnabled = $user.AccountEnabled
SkuId = $license.SkuId
SkuPartNumber = $skuMap[$license.SkuId.Guid]
}
}
}
$licenseInventory | Sort-Object SkuPartNumber,UserPrincipalName5. Inspect a candidate's service plans
Get-MgUserLicenseDetail shows plans and their states. A disabled plan does not prove the whole SKU is redundant; compare remaining plans and required capabilities.
$candidate = 'alex.tremblay@contoso.example'
Get-MgUserLicenseDetail -UserId $candidate -All |
Select-Object SkuPartNumber,SkuId,
@{n='ServicePlans';e={($_.ServicePlans | ForEach-Object {
"$($_.ServicePlanName):$($_.ProvisioningStatus)"
}) -join '; '}}6. Separate direct and inherited assignments
An empty AssignedByGroup normally means direct assignment; a GUID identifies the source group. A user can receive the same SKU from multiple sources. Removing an inherited license directly does not fix the membership that will assign it again.
$assignmentStates = foreach ($user in $licensedUsers) {
$detail = Get-MgUser -UserId $user.Id -Property UserPrincipalName,LicenseAssignmentStates
foreach ($state in $detail.LicenseAssignmentStates) {
[pscustomobject]@{
UserPrincipalName = $detail.UserPrincipalName
SkuPartNumber = $skuMap[$state.SkuId.Guid]
Source = if ($state.AssignedByGroup) { "Group: $($state.AssignedByGroup)" } else { 'Direct' }
State = $state.State
Error = $state.Error
}
}
}
$assignmentStates | Sort-Object UserPrincipalName,SkuPartNumber7. Inventory groups that assign licenses
Group licensing simplifies governance, but an oversized group can waste seats. Nested groups do not pass licenses to nested members; validate direct members and the HR process that manages them.
$licensedGroups = Get-MgGroup -All -Property Id,DisplayName,GroupTypes,AssignedLicenses |
Where-Object { $_.AssignedLicenses.Count -gt 0 }
$licensedGroups | ForEach-Object {
[pscustomobject]@{
GroupName = $_.DisplayName
GroupId = $_.Id
AssignedSkus = ($_.AssignedLicenses | ForEach-Object {
$skuMap[$_.SkuId.Guid]
}) -join ', '
}
}8. Find errors before optimizing
An error can indicate insufficient licenses, missing usage location, or incompatible plans. It is neither a recoverable license nor proof of inactivity; fix the cause or document the exception.
$licenseErrors = $assignmentStates |
Where-Object { $_.State -ne 'Active' -or $_.Error }
$licenseErrors |
Select-Object UserPrincipalName,SkuPartNumber,Source,State,Error |
Sort-Object SkuPartNumber,UserPrincipalName9. Export available Microsoft 365 activity
The D180 report covers at most 180 days and fields vary by product. Last activity is intentional activity observed by a service, not a universal measure of value. Identities are concealed by default in many tenants; change that privacy setting only when governance permits it.
$reportPath = Join-Path $env:TEMP 'M365-ActiveUsers-D180.csv'
Get-MgReportOffice365ActiveUserDetail -Period D180 -OutFile $reportPath
$usage = Import-Csv $reportPath
$usage | Select-Object 'User Principal Name','Assigned Products',
'Exchange Last Activity Date','OneDrive Last Activity Date',
'SharePoint Last Activity Date','Teams Last Activity Date'10. Compare the last successful Entra sign-in
LastSuccessfulSignInDateTime includes successful interactive and non-interactive sign-ins, but data is not backfilled before December 1, 2023. Blank can mean never used or unavailable data. The property requires Entra ID P1/P2 and AuditLog.Read.All, and updates can be delayed.
$signIns = Get-MgUser -All -Filter 'assignedLicenses/$count ne 0' -ConsistencyLevel eventual -Property Id,DisplayName,UserPrincipalName,AccountEnabled,SignInActivity
$signIns | Select-Object DisplayName,UserPrincipalName,AccountEnabled,
@{n='LastSuccessfulSignInUtc';e={$_.SignInActivity.LastSuccessfulSignInDateTime}} |
Sort-Object LastSuccessfulSignInUtcBuild a review queue, not a deletion robot
Export a timestamped inventory, then add owner, decision, rationale, and due date. Have each case approved by the business manager and retention or security owners. Lack of activity must never be the only criterion.
| Signal | Careful reading | Required validation |
|---|---|---|
| Disabled account + direct license | High-priority candidate | Departure, retention, mailbox, OneDrive, apps |
| No D180 activity | No observed activity in that window | Manager, seasonality, actual product use |
| Old last sign-in | Supporting indicator | Service identity, non-interactive clients, coverage |
| Inherited license | Fix the source | Group owner, rule, collective impact |
| Assignment error | Provisioning problem | UsageLocation, capacity, plans, need |
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$licenseInventory | Export-Csv ".\license-inventory-$stamp.csv" -NoTypeInformation -Encoding utf8
$assignmentStates | Export-Csv ".\license-sources-$stamp.csv" -NoTypeInformation -Encoding utf8Pilot removal: explicit change and rollback
The next command changes the tenant and is not one of the ten collections. Run it only after confirming a direct assignment, Exchange, OneDrive, Teams and Purview dependencies, retention, and owner approval. For an inherited license, review group membership through its owning process.
Test one non-critical account, capture its SKUs, wait for propagation, and validate sign-in and services. Rollback reassigns the SKU if capacity and plans remain compatible; it does not guarantee recovery of data deleted by a lifecycle process.
$userId = 'alex.tremblay@contoso.example'
$skuId = [guid]'00000000-0000-0000-0000-000000000000'
# Capture before the approved change
$before = Get-MgUserLicenseDetail -UserId $userId -All
$before | Select-Object SkuPartNumber,SkuId |
Export-Csv '.\before-license-change.csv' -NoTypeInformation
Set-MgUserLicense -UserId $userId -AddLicenses @() -RemoveLicenses @($skuId)$addLicense = @{ SkuId = $skuId }
Set-MgUserLicense -UserId $userId -AddLicenses @($addLicense) -RemoveLicenses @()What this workflow does not replace
These commands show where to focus review. They do not independently calculate contract cost, commitments, discounts, add-ons, compliance obligations, or the correct target SKU. A Microsoft 365 assessment connects inventory, activity, identities, groups, retention, security, and contracts to produce a validated plan instead of mass removal.