๐ณ Recipe ยท Licensing & Cost Optimization
Find Disabled Users Who Still Have Licenses Assigned
Identify deactivated Microsoft 365 accounts that are still consuming paid licenses so you can reclaim them and cut costs
Complexity
Beginner
Impact
cost-savings + license-optimization + offboarding-hygiene + tenant-audit
Context
Why This Matters
When employees leave the organization or accounts are retired, admins frequently disable the account (set accountEnabled = false) but forget to remove the assigned Microsoft 365 licenses. Those licenses continue to be billed every month even though no one can sign in to use them.
This recipe enumerates every disabled user in your tenant and reports which ones still hold paid license SKUs. It is a fast, low-risk audit that almost always surfaces recoverable spend โ particularly after offboarding waves, M&A events, or periodic license true-ups.
When to run this
- Monthly or quarterly license reconciliation
- After a batch offboarding or reorganization
- Before renewing an enterprise agreement or buying additional seats
- As part of a tenant health / cost-optimization review
Expected Outcomes
After completing this recipe you will have:
- A complete list of every disabled user account in the tenant that still has one or more licenses assigned
- Each user's display name, UPN, and the human-readable SKU part numbers (e.g.
SPE_E5,EXCHANGESTANDARD) - A defensible cost-recovery target โ each reclaimed license can be unassigned or the subscription reduced at renewal
- Identification of shared/service accounts (auto attendants, shared mailboxes, resource accounts) that may legitimately hold licenses while disabled
Risks & Considerations
Important considerations
- Don't bulk-remove licenses blindly. Some disabled accounts are intentional โ for example,
PHONESYSTEM_VIRTUALUSERlicenses on Teams auto attendants / call queues, or shared mailboxes that require an Exchange license for over-50 GB storage or litigation hold. - Data retention risk. Removing an Exchange Online license from a disabled user starts a 30-day countdown before the mailbox is permanently deleted. Place the mailbox on Litigation Hold or convert to a shared mailbox first if you need to preserve data.
- OneDrive content. Removing a license can trigger OneDrive retention (default 30 days). Reassign ownership or export content first if needed.
- Group-based licensing. If licenses are assigned via group membership, you must remove the user from the licensing group โ unassigning directly will fail or re-apply.
- Compliance. Check whether the account is subject to legal hold, eDiscovery case, or regulatory retention before making changes.
Required Permissions
| Permission | Why It's Needed |
|---|---|
| User.Read.All | Read user account properties including accountEnabled and assignedLicenses |
| Organization.Read.All | Read subscribedSkus to map SKU IDs to human-readable SKU part numbers |
| Directory.Read.All | Alternative / broader read permission if tenant policy requires it |
The fastest way to get this done: just ask Dex. Copy the prompt below and paste it into your Dex conversation.
For IT Admins
Paste into Dex Pro
Manual walkthrough โ Microsoft 365 Admin Center
The admin center does not offer a single filter for "disabled users with licenses," so this is a two-step filter/cross-reference exercise. For tenants with more than a few dozen disabled users, use the API or PowerShell approach instead.
Sign in to the Microsoft 365 admin center as a Global Reader, User Admin, or License Admin.
Navigate to .
Click Filter and choose Sign-in blocked. This returns accounts where
accountEnabled = false.Click Export users (top toolbar) to download the filtered set as a CSV.
Open the CSV in Excel. The
Licensescolumn shows assigned product names. Filter or sort to find rows where this column is not empty.For each flagged user, click the user in the admin center and review to confirm which SKUs are assigned and whether removal is appropriate.
To reclaim a license, uncheck the product on that tab and click Save changes. Consider converting the mailbox to shared first if data retention is a concern.
Tip: The admin center also has a dedicated view, but it shows consumption per SKU โ not per user account status.
Graph API recipe
Use a filtered /users query combined with /subscribedSkus to resolve SKU IDs to friendly names.
Step 1 โ List disabled users with license detail
GET https://graph.microsoft.com/v1.0/users?$filter=accountEnabled eq false&$select=id,displayName,userPrincipalName,assignedLicenses&$top=999
Authorization: Bearer {access_token}
ConsistencyLevel: eventualPage through results via @odata.nextLink. Client-side, keep only users whose assignedLicenses array is non-empty.
Example response
{
"value": [
{
"id": "a1b2...",
"displayName": "Constantine Ward",
"userPrincipalName": "cward@contoso.com",
"assignedLicenses": [
{ "skuId": "06ebc4ee-...", "disabledPlans": [] }
]
}
]
}Step 2 โ Resolve SKU IDs to names
GET https://graph.microsoft.com/v1.0/subscribedSkusBuild a dictionary of skuId โ skuPartNumber (e.g. SPE_E5, ENTERPRISEPACK, EXCHANGESTANDARD) and map each user's licenses.
Step 3 โ (Optional) Remove a license
POST https://graph.microsoft.com/v1.0/users/{userId}/assignLicense
Content-Type: application/json
{
"addLicenses": [],
"removeLicenses": ["06ebc4ee-1bb5-47dd-8120-11324bc54e06"]
}Review each account before removal โ service accounts and auto attendants may require their licenses to function.
PowerShell walkthrough (Microsoft.Graph module)
The Microsoft Graph PowerShell SDK offers the cleanest scripted path. Install once with Install-Module Microsoft.Graph -Scope CurrentUser.
Connect with the necessary scopes:
Connect-MgGraph -Scopes "User.Read.All","Organization.Read.All"Build a SKU lookup so output is readable:
$skuMap = @{} Get-MgSubscribedSku | ForEach-Object { $skuMap[$_.SkuId] = $_.SkuPartNumber }Query disabled users and filter for those with licenses:
$disabled = Get-MgUser -Filter "accountEnabled eq false" ` -Property Id,DisplayName,UserPrincipalName,AssignedLicenses ` -All $withLicenses = $disabled | Where-Object { $_.AssignedLicenses.Count -gt 0 }Project friendly output and export:
$withLicenses | ForEach-Object { [pscustomobject]@{ DisplayName = $_.DisplayName UserPrincipalName = $_.UserPrincipalName Licenses = ($_.AssignedLicenses.SkuId | ForEach-Object { $skuMap[$_] }) -join ", " } } | Export-Csv -Path ".\DisabledUsersWithLicenses.csv" -NoTypeInformation
The downloadable script below wraps this flow with parameters, pagination, and optional CSV export.
Next steps