You add a shiny new "M365 E3 — Licensed Users" security group, assign the Microsoft 365 E3 product to it, and drop 400 users in. The Licenses page shows the group as configured. Job done, coffee earned. Three weeks later a helpdesk ticket lands: a user has no Exchange mailbox and Teams keeps telling them they need a licence. You open their profile and, sure enough, the licence looks assigned via the group. Nothing in the group view, the product view, or the user's Licenses tab flashes red. The failure is real, it has been sitting there for three weeks, and the only place it is actually recorded is inside a per-user property most admins have never queried: licenseAssignmentStates.
This post walks through exactly why a group licence assignment can succeed at the group level and quietly fail for individual members, how to confirm one user's real state before you trust any tooling, and a read-only PowerShell script that enumerates every user in the tenant and surfaces every licence assignment sitting in an error state — along with which group put it there.
The problem: the group is "assigned", the user has nothing
Group-based licensing does not assign a licence to a group as a single atomic thing that either works or does not. Under the hood, Entra ID takes the products you assigned to the group and processes them per member, one user at a time. For most members that processing succeeds and the licence lands. For some members it can fail — and the failure is scoped to that one user, not to the group.
The reasons a single member's assignment fails are well documented, and every one of them is a normal thing that happens in a live tenant:
You added more members than you have available seats for one of the products in the group. Entra ID licenses members until the pool runs dry; everyone after that fails with a count error.
A service plan in the group's product is mutually exclusive with a plan the user already has from another product assigned directly or via a different group. Entra ID will not assign both.
A service in the product is not available in the user's usage location, or the user has no allowed location set. The product cannot be provisioned for that user until the location is corrected.
The trap is that none of this is visible where you would instinctively look. The group shows the product as assigned. The product's overview shows a seat count that looks plausible. The affected user's Licenses tab shows the licence as inherited from the group. Everything reads as "fine" because the group-level operation genuinely did complete — it just completed with a per-user error tucked away that no summary view rolls up to your attention.
Why it happens: licenseAssignmentStates and the assignedByGroup field
Every Entra user object carries a collection called licenseAssignmentStates. It holds one entry per licence assignment that applies to the user, and it is the single source of truth for how each licence reached them and whether it landed cleanly. Microsoft documents the entry (the licenseAssignmentState resource type) with these fields:
| Field | What it tells you |
|---|---|
skuId | The product (SKU) this entry is for. |
assignedByGroup | The key field. If the licence was assigned directly to the user, this is null. If it was inherited through a group membership, this contains the object ID of the group. |
state | Current state of the assignment. Documented values: Active, ActiveWithError, Disabled, Error. |
error | The failure reason. null when the assignment succeeded; otherwise one of the documented error values below. |
disabledPlans | Service plans switched off within this assignment. |
lastUpdatedDateTime | When the state of this assignment was last updated. |
That assignedByGroup field is what makes this problem tractable. It is the difference between "this user has a broken licence" and "this user has a broken licence because of this specific group." Straight from the Microsoft Graph documentation: if the field is populated, the licence is inherited from the group whose ID it contains; if it is null, the licence is directly assigned. That is the exact discriminator the script later uses to attribute every error back to its source group.
When the assignment fails, the error field is set to a specific, documented value. These are the real values — not paraphrases — and knowing them tells you the root cause at a glance:
| error value | Meaning |
|---|---|
CountViolation | Not enough available licences for one of the products. Buy more seats, or free some up. |
MutuallyExclusiveViolation | A service plan in this product conflicts with a plan the user already has from another product. |
DependencyViolation | A service plan depends on another that is being removed — commonly triggered when a user is removed from a group and a still-needed underlying plan would go with it. |
ProhibitedInUsageLocationViolation | A service in the product is not permitted in the user's usage location (or a usable location is not set). |
UniquenessViolation | A unique attribute clashes — classically a duplicate proxyAddresses value, surfaced as "Proxy address is already being used". |
Other | Any failure outside the categories above. |
state of ActiveWithError is the sneakiest one. It means the licence is assigned and some service plans are working, but at least one plan hit an error and did not provision. The user looks licensed at a glance, so this is the state most likely to survive for weeks. Do not only look for Error; treat ActiveWithError as a finding too, which is exactly what the script does.Here is the mechanism end to end — one group assignment fanning out into per-user results, with the failure captured only on the affected user object:
licenseAssignmentStates entry, with assignedByGroup pointing back at the group. The group view and product view stay green.How to verify: check one user manually before trusting anything
Before running a tenant-wide script, confirm what a single affected user actually looks like. This does two things: it proves the property behaves the way this post describes in your tenant, and it gives you a concrete data point to check the script's output against. There are two quick ways.
The portal way. In the Microsoft 365 admin center, the errors are surfaced — you just have to open the right tab. Go to Billing › Licenses, select the product, and open the Errors & issues tab to see the list of users who hit a problem for that product. Select a user to see which error they encountered.
The PowerShell way. The portal tab is per-product and easy to miss; the definitive check is to read the property directly for one user. This is a single read, and it is the exact shape the full script consumes at scale:
Two things to read here. First, AssignedByGroup is populated with a GUID — so this licence is inherited from a group, not assigned directly, and that GUID is the group to go fix. Second, Error reads MutuallyExclusiveViolation, so the root cause is a service-plan conflict, not a seat shortage. That is the whole diagnosis in two fields.
User.Read.All, Directory.Read.All and Organization.Read.All — the last one so Get-MgSubscribedSku can translate a raw skuId into a readable product name. No write scope is needed or wanted for auditing.The fix: Get-LicenseAssignmentErrorReport.ps1
Opening the Errors & issues tab for every product, or reading one user at a time, does not scale past a handful of checks. The companion script does the same read across the whole tenant: it enumerates every user, walks each entry in licenseAssignmentStates, flags anything whose state is Error or ActiveWithError (or whose error is set), resolves the SKU to a product name and the assignedByGroup ID to a group display name, and prints a per-user, per-error report.
Download Get-LicenseAssignmentErrorReport.ps1 from the Imran76Awan/Daily-Tasks repository on GitHub — no sign-in required. It is read-only and cannot change anything in your tenant — but always validate any script in your own environment before you rely on it.
It supports two authentication modes. For unattended runs (a scheduled task or an Azure Automation runbook) use app-only certificate auth with -TenantId, -ClientId and -CertificateThumbprint. For a quick interactive check from your workstation use -UseDeviceCode, which requests the three read scopes and nothing more. Here is the initial run, before anyone has fixed the conflict from earlier:
The classification core is deliberately simple, and its error handling is the important part. It treats "this user simply has no error entry" as a non-event, but treats any failed Graph call as a hard failure that must be surfaced — never swallowed into a falsely clean result:
If any read — the connection, Get-MgSubscribedSku, Get-MgUser, or a group lookup — fails, the script prints a clear error, sets an internal $hadError flag, and exits with code 1. It will not print "0 errors found, all healthy" on the back of a query that never actually ran. Exit codes are machine-readable: 0 means a clean scan with no findings, 2 means findings were detected, and 1 means the run itself failed and the numbers cannot be trusted. Add -ExportCsv to drop the findings to a timestamped CSV for a ticket or an audit trail.
Proof it worked: re-run after fixing the root cause
Take Jane Okoro from the report above. Her error was MutuallyExclusiveViolation: she already held a standalone service plan directly that conflicts with a plan inside the E3 product the group assigns. The fix is a deliberate, manual one — remove the conflicting direct assignment (or disable the conflicting plan on one side) — and then let Entra reprocess. Crucially, the script did not and cannot do that for you; it only told you precisely which user, which group, and which conflict. After the direct conflict is cleared and the group reprocesses that member, the same read-only script run comes back clean:
Zero error entries and a CLEAN status (exit code 0) is the aggregate signal. But as with any per-object problem, the honest final proof is to check the specific object you fixed — re-read Jane's licenseAssignmentStates and confirm the entry for that SKU now reads state = Active, error = null, with assignedByGroup still populated:
That is the loop closed: the tenant-wide scan is clean, and the one user who was silently missing services for three weeks now has the licence provisioning correctly, still inherited from the group as intended. Run the script on a schedule and a future CountViolation the day you run out of seats becomes something you catch in an overnight report — not something a user catches for you a fortnight later.
References
- licenseAssignmentState resource type — Microsoft Graph v1.0 (the
state,errorandassignedByGroupfield definitions) - PowerShell examples for group-based licensing — Microsoft Entra ID (Get-MgUser property usage, assignedByGroup logic, and the read scopes)
- Assign or unassign licenses to a group in the Microsoft 365 admin center — Microsoft Learn (the Errors & issues tab and usage-location behaviour)
- Identify and resolve license assignment problems for a group — Microsoft Entra (full description of each error type)
- Sander Berkouwer (MVP): Using dynamic memberships to assign mutually exclusive P1 and P2 licenses — dirteam.com (supplementary; a real-world take on avoiding the mutually-exclusive conflict)
Have you ever actually queried licenseAssignmentStates across your whole tenant, or are you assuming group-based licensing is healthy because nobody has complained this week? Run the script, compare it against the Errors & issues tab for one product, and see how many silent failures turn up — I would genuinely like to know how common the "assigned but broken" state is out there.