The practical answer
Use licenseAssignmentStates when you need to explain not only which SKU a user has, but who or what assigned it, whether the licensing service processed it successfully, which service plans are disabled, and when the state last changed. The report below creates one row per user, SKU, and assignment source without modifying the tenant.
Explain every source
Separate direct assignments from group-based assignments and resolve each source group to a readable name.
Find processing failures
Surface ActiveWithError and Error states together with the licensing-service error returned by Microsoft Graph.
Detect assignment drift
Flag users who receive the same SKU from several groups or from a group and a direct assignment.
Why assignedLicenses is not enough
The assignedLicenses collection is useful for checking the effective licence SKUs on a user, but it is not a complete provenance record. If the same SKU is inherited from multiple groups, or inherited from a group while also assigned directly, an effective-licence view can hide the operational complexity.
The licenseAssignmentStates property provides the missing detail. Microsoft Graph returns assignedByGroup, skuId, disabledPlans, state, error, and lastUpdatedDateTime for each current assignment source. This is a snapshot of current states, not a historical event log.
Prerequisites and permissions
Install the Microsoft Graph PowerShell SDK with Install-Module Microsoft.Graph -Scope CurrentUser. For an interactive delegated audit, request User.Read.All, Group.Read.All, and LicenseAssignment.Read.All. Tenant policy may require administrator consent, and the signed-in account must hold a directory role allowed to read the relevant directory and licensing information.
- User.Read.All retrieves users and the non-default licenseAssignmentStates property.
- Group.Read.All resolves source group IDs to readable display names.
- LicenseAssignment.Read.All reads subscribed SKUs so the export can show skuPartNumber instead of only a GUID.
Use a dedicated read-only audit identity where possible and request only the permissions the report needs. Group-based licensing also requires an eligible entitlement for every benefiting user and enough product licences for the unique users covered by the licensed groups.
Important design limits
Nested groups do not extend licence assignment
Licences assigned to a parent group are not applied through nested-group membership. Audit direct membership in the licensed group; do not assume that a nested child group passes the licence to its users.
A direct assignment is not automatically a mistake
Direct assignments may be valid for temporary access, specialist add-ons, pilot users, break-glass processes, or documented exceptions. The audit should identify unexplained direct assignments instead of blindly eliminating all of them.
Duplicate sources do not necessarily consume duplicate seats
A user can receive the same SKU from several assignment sources. This is usually a governance and troubleshooting concern, not proof that two product seats are billed for the same person. Review source groups, service-plan configuration, and commercial counts separately.
What the enhanced report contains
- User display name, UPN, object ID, and account state.
- SKU part number and SKU ID.
- Direct or group-based assignment type and source group.
- Assignment state, Graph error value, and disabled service-plan IDs.
- Last update timestamp, overlap flag, and a summary of every source for the same user and SKU.
PowerShell audit script
The following script is read-only. It resolves only groups that actually occur as licence sources, retains unresolved IDs, and creates one CSV row per user, SKU, and assignment source.
# Read-only Microsoft 365 group-based licensing audit
# Requires: Install-Module Microsoft.Graph -Scope CurrentUser
$requiredScopes = @(
"User.Read.All"
"Group.Read.All"
"LicenseAssignment.Read.All"
)
Connect-MgGraph -Scopes $requiredScopes -NoWelcome
$skuLookup = @{}
Get-MgSubscribedSku -All | ForEach-Object {
$skuLookup[$_.SkuId.ToString()] = $_.SkuPartNumber
}
$users = Get-MgUser -All -Property @(
"Id"
"DisplayName"
"UserPrincipalName"
"AccountEnabled"
"LicenseAssignmentStates"
)
$sourceGroupIds = $users.LicenseAssignmentStates |
Where-Object { -not [string]::IsNullOrWhiteSpace($_.AssignedByGroup) } |
ForEach-Object { $_.AssignedByGroup } |
Sort-Object -Unique
$groupLookup = @{}
foreach ($groupId in $sourceGroupIds) {
try {
$group = Get-MgGroup -GroupId $groupId -Property "Id,DisplayName" -ErrorAction Stop
$groupLookup[$groupId] = $group.DisplayName
}
catch {
$groupLookup[$groupId] = $null
}
}
$report = foreach ($user in $users) {
$assignments = @($user.LicenseAssignmentStates)
foreach ($assignment in $assignments) {
$skuId = $assignment.SkuId.ToString()
$sourceGroupId = $assignment.AssignedByGroup
$assignmentType = if ([string]::IsNullOrWhiteSpace($sourceGroupId)) {
"Direct"
} else {
"Group-based"
}
$sameSkuAssignments = @(
$assignments | Where-Object { $_.SkuId.ToString() -eq $skuId }
)
$sourceSummary = $sameSkuAssignments | ForEach-Object {
if ([string]::IsNullOrWhiteSpace($_.AssignedByGroup)) {
"Direct"
} elseif ($groupLookup[$_.AssignedByGroup]) {
"Group: $($groupLookup[$_.AssignedByGroup])"
} else {
"Group ID: $($_.AssignedByGroup)"
}
}
$sourceGroupName = if ($sourceGroupId) {
$groupLookup[$sourceGroupId]
} else {
$null
}
$sourceResolution = if (-not $sourceGroupId) {
"Not applicable"
} elseif ($sourceGroupName) {
"Resolved"
} else {
"Unresolved"
}
[PSCustomObject]@{
DisplayName = $user.DisplayName
UserPrincipalName = $user.UserPrincipalName
UserId = $user.Id
AccountEnabled = $user.AccountEnabled
LicenseSku = $skuLookup[$skuId]
LicenseSkuId = $skuId
AssignmentType = $assignmentType
SourceGroupName = $sourceGroupName
SourceGroupId = $sourceGroupId
SourceResolution = $sourceResolution
AssignmentState = $assignment.State
AssignmentError = $assignment.Error
DisabledPlanIds = ($assignment.DisabledPlans | ForEach-Object { $_.ToString() }) -join ";"
LastUpdatedDateTime = $assignment.LastUpdatedDateTime
SourcesForSameSku = $sameSkuAssignments.Count
HasOverlappingSources = ($sameSkuAssignments.Count -gt 1)
SameSkuSourceSummary = ($sourceSummary -join " | ")
}
}
}
$outputPath = Join-Path -Path (Get-Location) -ChildPath "m365-group-license-audit.csv"
$report |
Sort-Object AssignmentState, AssignmentError, LicenseSku, UserPrincipalName, AssignmentType |
Export-Csv -Path $outputPath -NoTypeInformation -Encoding UTF8
Write-Host "Export complete: $outputPath"
Write-Host "Rows: $($report.Count)"
Write-Host "Errors: $(@($report | Where-Object AssignmentError).Count)"
Write-Host "Direct assignments: $(@($report | Where-Object AssignmentType -eq 'Direct').Count)"
Write-Host "Overlapping sources: $(@($report | Where-Object HasOverlappingSources).Count)"
Write-Host "Unresolved groups: $(@($report | Where-Object SourceResolution -eq 'Unresolved').Count)"How the script works
1. Build a SKU lookup
Graph returns licence SKUs as GUIDs. Get-MgSubscribedSku supplies the tenant's skuPartNumber, which is easier to filter and discuss. The report keeps the GUID because it is the stable identifier used by Graph.
2. Request licenseAssignmentStates explicitly
Get-MgUser returns only a default subset of user properties. The script explicitly requests LicenseAssignmentStates; omitting it can produce an empty report even when users are licensed.
3. Resolve only source groups in use
Large tenants may contain thousands of groups. The script collects unique assignedByGroup values and resolves only those IDs. If a group cannot be resolved, the ID remains in the report and the source is marked Unresolved.
4. Detect overlapping sources
More than one assignment state for the same user and SKU sets HasOverlappingSources to True. The summary then shows whether the overlap is direct plus group-based, or several group sources.
Reading the output
AssignmentType = Direct
Start with direct assignments when the tenant is intended to use role-based groups. Ask for an owner and business reason. If it is a valid exception, document a review date. If it is legacy drift, confirm that a replacement group assignment is active before removing it.
HasOverlappingSources = True
Review whether the overlap is intentional. Two role groups may legitimately provide the same SKU, but different disabled-plan configurations can make the effective result difficult to explain. Record the authoritative group for each role and simplify sources where this is safe.
SourceResolution = Unresolved
An unresolved ID can mean the group no longer exists, the audit identity cannot read it, or directory replication has not completed. Do not classify the assignment as orphaned until an appropriately authorised directory reader verifies the object.
AssignmentState = ActiveWithError or Error
Prioritise these rows. Microsoft Graph documents CountViolation, MutuallyExclusiveViolation, DependencyViolation, ProhibitedInUsageLocationViolation, UniquenessViolation, and Other. The CSV identifies the user, SKU, source, and state; confirm the precise remediation in the Microsoft 365 admin centre and current Microsoft documentation before changing a licence.
DisabledPlanIds
The same product can be assigned with different service plans disabled. For a deeper audit, build a service-plan lookup from Get-MgSubscribedSku.ServicePlans and add readable plan names. This is especially useful when overlapping groups configure the same SKU differently.
Useful follow-up filters
# Licensing-service failures
$report | Where-Object {
$_.AssignmentState -in @("ActiveWithError", "Error") -or $_.AssignmentError
}
# Direct assignments for enabled accounts
$report | Where-Object {
$_.AssignmentType -eq "Direct" -and $_.AccountEnabled
}
# Same user and SKU assigned by more than one source
$report | Where-Object HasOverlappingSources
# Source groups that could not be resolved
$report | Where-Object SourceResolution -eq "Unresolved"A practical remediation workflow
- Export and preserve the raw report with a timestamp.
- Triage Error and ActiveWithError rows before governance cleanup.
- Confirm available licence capacity and user usage locations.
- Give every valid direct-assignment exception an owner and review date.
- Review overlaps, especially where groups disable different service plans.
- Add users to the destination group and confirm the assignment is active before removing the old source.
- Re-export after changes, compare exception counts, and schedule a recurring read-only audit.
Microsoft's current guidance for moves between licensed groups is destination first: add the user to the new group, confirm that the licence is applied, and only then remove the user from the original group. Removing the source first can temporarily remove licensed services while processing catches up.
What this report does not prove
- It does not prove that every assignment is contractually compliant.
- It does not calculate invoices or duplicate commercial billing.
- It does not validate whether users should access every workload in the SKU.
- It is not a historical timeline and does not replace product terms or access reviews.
Recommended operating model
Run the export monthly and after major identity or licensing changes. Track errors, undocumented direct assignments, overlapping sources, unresolved groups, and disabled or departed accounts that retain active assignments. The target is not necessarily zero direct assignments or zero overlaps. The target is that every exception has an owner, a reason, and a review date—and that no licensing error remains invisible.
Microsoft sources
- licenseAssignmentState resource type
- What is group-based licensing in Microsoft Entra ID?
- Assign or unassign licences to a group and manage errors
- Get-MgUser


