The Problem#
You’ve moved to group-based licensing. All your Microsoft 365 licenses are assigned through Entra ID groups. Clean, governed, auditable.
Except someone gave a contractor a direct license assignment six months ago. And someone else added a license manually during a troubleshooting session and forgot to remove it. And three former employees still have licenses because they were removed from the group but had a direct assignment that nobody noticed.
These users are outside your licensing governance model. They cost money, they might have access they shouldn’t have, and you don’t know they exist until the next true-up when Microsoft asks why you’re using 47 more licenses than you’re paying for.
How It Works#
The script checks LicenseAssignmentStates on every user. Each license assignment in Entra ID has an AssignedByGroup property – if it’s null, the license was assigned directly, not through a group. No need to know which licensing group to compare against. No transitive membership resolution. The data is already there on the user object.
# Find all users with direct license assignments
.\Manage-DirectAssignedLicenses.ps1
# Export to CSV for governance review
.\Manage-DirectAssignedLicenses.ps1 -ExportCsv "C:\Reports\DirectLicenses.csv"
# Remove direct assignments only when the same SKU is also assigned via group
.\Manage-DirectAssignedLicenses.ps1 -RemoveSafe -LogFile "C:\Logs\LicenseCleanup.log"
# See what -RemoveSafe would do without making changes
.\Manage-DirectAssignedLicenses.ps1 -RemoveSafe -WhatIf
# Remove ALL direct assignments regardless of group coverage
.\Manage-DirectAssignedLicenses.ps1 -RemoveDirect
# Target a specific tenant, skip the grid view popup
.\Manage-DirectAssignedLicenses.ps1 -TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -NoGridViewWithout a removal switch, the script is read-only – it reports what it finds and doesn’t touch anything. With -RemoveSafe or -RemoveDirect, it acts on the results and logs every action. Add -WhatIf to either removal mode to preview what would happen without touching anything.
Prerequisites#
- PowerShell 5.1+ with the Microsoft Graph PowerShell SDK
Microsoft.Graph.Authentication(forConnect-MgGraph)Microsoft.Graph.Users(forGet-MgUserandSet-MgUserLicense)- Permissions (delegated):
User.Read.All,Organization.Read.All - With
-RemoveDirector-RemoveSafe:User.ReadWrite.Allinstead ofUser.Read.All
The script uses delegated permissions and requires interactive sign-in. It requests the correct scope automatically based on which switches you use. Organization.Read.All is needed to resolve SKU GUIDs to friendly names via subscribedSkus. Without it, the report would show raw GUIDs instead of “ENTERPRISEPACK” or “SPE_E5”. For a full list of SKU part numbers, see Microsoft’s product names and service plan identifiers.
Implementation#
Detecting Direct Assignments#
The key insight is LicenseAssignmentStates.AssignedByGroup. Every license assignment on a user has this property. If it’s populated with a group ID, the license was assigned through that group. If it’s null, it was assigned directly:
$directAssignments = @(
$user.LicenseAssignmentStates |
Where-Object { $null -eq $_.AssignedByGroup }
)This is more reliable than comparing users against a licensing group. You don’t need to know which group to check, it works with multiple licensing groups, and it catches direct assignments on any SKU – not just the one your group manages.
SKU Name Resolution#
License SKUs are stored as GUIDs. The script resolves them to human-readable names using subscribedSkus:
$skuResponse = Invoke-MgGraphRequest -Method GET `
-Uri 'https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuId,skuPartNumber'
$skuMap = @{}
foreach ($sku in $skuResponse.value) {
$skuMap[$sku.skuId] = $sku.skuPartNumber
}The report then shows ENTERPRISEPACK instead of 6fd2c87f-b296-42f0-b197-1e91e994b900. If you’ve ever stared at a list of license GUIDs trying to figure out what they are, you’ll appreciate this.
Reading the Output#
Each row in the report is one directly assigned license on one user. The columns:
- DisplayName / UserPrincipalName – who has the direct assignment
- Department – helpful for figuring out who asked for it and who should clean it up
- AccountEnabled – disabled accounts with direct licenses are almost always waste
- License – the specific SKU that’s directly assigned, by name
- DirectAssigned – always True in this report (that’s what we’re looking for)
- GroupAssigned – True if the same SKU is also assigned through a group with an Active state, False if it’s only direct
GroupAssigned = True means -RemoveSafe will remove it – the group covers the user, so the direct assignment is redundant. GroupAssigned = False means the direct assignment is the only source of that license. If you remove it, the user loses access to that SKU. Either add them to a licensing group first, or use -RemoveDirect after verifying they don’t need it.
The script also flags disabled accounts with direct licenses separately. These are almost always immediate cleanup candidates – a disabled account doesn’t need a license, and a direct assignment on a disabled account means nobody is managing it through groups.
Removing Direct Assignments#
The script has two removal modes, and you use exactly one or neither:
-RemoveSafe removes a direct assignment only when the same SKU is also assigned through a group and that group assignment is in an Active state. The API removes the SKU entirely via assignLicense and the group immediately re-grants it – the user never loses access, but this only works if the group assignment is healthy. If the group assignment is in an error state, or if the SKU has no group coverage at all, it’s skipped and logged as SKIP. This is the safe choice for routine cleanup.
-RemoveDirect removes all direct assignments unconditionally, regardless of whether a group provides the same SKU. Use this when you’ve verified the report and want to clear everything, or when cleaning up disabled accounts that shouldn’t have any licenses at all.
Both modes support -WhatIf – add it to see what would be removed without touching anything. Both use Set-MgUserLicense with -RemoveLicenses to remove one SKU at a time, and both log every action:
[2025-02-26 14:23:01] OK [email protected] | ENTERPRISEPACK removed
[2025-02-26 14:23:02] SKIP [email protected] | SPE_E5 (no active group coverage)
[2025-02-26 14:23:03] FAIL [email protected] | FLOW_FREE | Request failedAdd -LogFile "C:\Logs\cleanup.log" to write the same output to a file. The log includes timestamps, the action taken (OK/SKIP/FAIL), the user, and the SKU name.
A typical workflow: run the script without switches first to review the report. Export to CSV if you need sign-off. Run with -RemoveSafe -WhatIf to preview what would change. Then run with -RemoveSafe to clean up the safe ones, and decide case-by-case on the rest.
Security Considerations#
The script runs under your identity in delegated context. User.ReadWrite.All with -RemoveDirect can revoke licenses for every user in the tenant – don’t run that on a whim. Run the report first, review it, use -WhatIf, and don’t hand the script to someone who hasn’t read the output.
The CSV and log files contain UPNs, department info, and account status. Don’t drop them in a shared folder with broad access.
Organization.Read.All is read-only – just used for SKU name resolution. If that scope is a problem in your environment, the script will still run, but you’ll see GUIDs instead of friendly names.
-RemoveDirect removes licenses without checking group coverage. If a user has a direct E5 and no group assignment, -RemoveDirect revokes their E5. That’s exactly what you want for disabled accounts. For active users, use -RemoveSafe or verify the report first.
When to Use This / When Not To#
Use this as a regular governance check – monthly or quarterly. Before license true-ups when you need to explain consumption. When auditing licensing costs. When migrating from direct to group-based licensing and you want to verify the migration is complete.
Start with -RemoveSafe for routine cleanup – it only removes redundant direct assignments where group coverage already exists. Disabled accounts with direct licenses are worth investigating immediately – they’re almost always waste, and -RemoveDirect is appropriate there.
Users with GroupCount = 0 are entirely outside your governance model and should be your first priority. Don’t just remove their licenses – figure out why they have direct assignments and add them to the right group first.
Don’t use this as a one-time cleanup and then forget about it. Direct assignments creep back in. Someone will add one during a troubleshooting session, or a vendor will assign one during an onboarding, or an automated process will create one. Run it regularly and make someone responsible for acting on the results.