A real-world licensing investigation, told honestly, so you don’t lose hours like we did.
🎬 The short story
We had a few disabled users who still had Microsoft Teams Phone licenses assigned. No matter what we tried in the Microsoft 365 or Entra ID portal… the license just would not go away.
The portal refused. Scripts half‑worked. Errors everywhere. And yet — Microsoft Graph fixed everything in one clean pass.
This article explains why.
😤 What we saw in the UI (UX)
In the Microsoft 365 / Entra portal, we tried the obvious steps:
- Remove Teams Phone from the user
- Check the licensing group
- Disable the account
And the portal responded with something like:
⚠️ “This license is assigned via group membership and cannot be removed.”
Sometimes the checkbox was greyed out. Sometimes the action “worked” but nothing changed.
The license looked stuck.
🧠 The first wrong assumption (very common)
We assumed this flow:
Licensing Group
→ Teams Phone (MCOEV)
→ User
So naturally, we searched for:
- A group assigning
MCOEV - A way to remove that license directly
But this assumption was wrong.
🔍 The real licensing flow (what was actually happening)
Here is the real flow inside Entra ID:
Licensing Group
→ Microsoft 365 E3 / E5
→ Service plans inside the license
→ Teams Phone (enabled switch)
→ User
Important: Teams Phone was not assigned as a standalone license. It was enabled as a service plan inside E3 / E5.
That means:
- No group visibly “assigned Teams Phone”
- The portal could not map the source
- Removing the license was blocked for safety
🧩 Why the license looked “stuck”
From the portal’s point of view:
- The license was group-based ✅
- The group did not obviously show Teams Phone ✅
- The assignment path was unclear ❌
So the UX followed a conservative rule:
If any part of the license is group-based and unclear → block removal.
That’s not a bug. That’s the portal protecting you from accidental mass license removal.
🧠 The breakthrough: stop asking the group, ask the user
Microsoft Graph exposes something the portal hides:
LicenseAssignmentStates
This property answers one golden question:
“For THIS user, who assigned THIS license?”
When we queried it, we finally saw entries like:
AssignedByGroup = e6689047-728e-4422-8ca2-35ad9c7ab0de
In plain English:
“This license exists because of THIS group, even if the group UI doesn’t show the feature.”
😬 The extra twist: mixed assignments
One user had an even messier situation:
- ✅ Teams Phone via group
- ❗ Another SKU assigned directly months or years ago
The UX saw:
“Part group-based, part direct — I’m not touching that.”
Graph, however, exposed both paths clearly.
✅ The final working logic (the one that fixed everything)
Once we trusted LicenseAssignmentStates, the solution became obvious:
User → Check LicenseAssignmentStates → If AssignedByGroup → Remove user from THAT group → If Assigned directly → Remove license directly
No guessing. No brute force. No collateral damage.
Result: Teams Phone removed cleanly for all problematic users.
📊 Why this only worked with Graph (not the UI)
- The UI does not expose license attribution details
- The UI applies safety rules when attribution is unclear
- Graph exposes the internal truth Entra ID already knows
So the final rule becomes:
If a license looks “stuck” in the UI, it usually means the UI cannot see the full assignment path — not that the system is broken.
🧠 The one lesson to remember
Groups advertise licenses. Users remember who actually assigned them.
When things get weird:
- Start from the user
- Read
LicenseAssignmentStates - Remove the license using the same path it came from
🔒 How to prevent this forever
Make sure all licensing groups include a dynamic rule like:
(user.accountEnabled -eq true)
That way:
- Disabled users auto-drop
- Teams Phone, E3, E5 auto-remove
- No “stuck” licenses ever again
✅ Final thought
The license was never stuck. We just needed to ask the right question — at the right place.
And that place was Microsoft Graph.
📜 PowerShell Script for License Removal
Here's a PowerShell script to manage license removal using Graph API:
Connect-MgGraph -Scopes "User.Read.All","Group.ReadWrite.All","Directory.ReadWrite.All"
$DomainFilter = "@domain.com"
$TeamsPhoneSkuPartNumber = "MCOEV"
# Resolve Teams Phone SKU ID
$TeamsPhoneSkuId = (Get-MgSubscribedSku |
Where-Object { $_.SkuPartNumber -eq $TeamsPhoneSkuPartNumber }).SkuId
$Users = Get-MgUser -All `
-Property Id,UserPrincipalName,AccountEnabled,LicenseAssignmentStates |
Where-Object {
$_.AccountEnabled -eq $false -and
$_.UserPrincipalName -like "*$DomainFilter"
}
foreach ($User in $Users) {
# Detect Teams Phone presence (direct OR inherited)
$PhoneAssignments = $User.LicenseAssignmentStates |
Where-Object { $_.SkuId -eq $TeamsPhoneSkuId }
if (-not $PhoneAssignments) { continue }
Write-Host "`n==========================================" -ForegroundColor Cyan
Write-Host "Disabled user with Teams Phone detected:" -ForegroundColor Yellow
Write-Host "User: $($User.UserPrincipalName)"
Write-Host ""
# --- GROUP-BASED assignments ---
$GroupIds = $PhoneAssignments |
Where-Object { $_.AssignedByGroup } |
Select-Object -ExpandProperty AssignedByGroup -Unique
if ($GroupIds) {
Write-Host "Inherited via licensing group(s):"
foreach ($Gid in $GroupIds) {
$G = Get-MgGroup -GroupId $Gid
Write-Host " - $($G.DisplayName) ($Gid)"
}
}
# --- DIRECT assignments ---
$Direct = $PhoneAssignments |
Where-Object { -not $_.AssignedByGroup }
if ($Direct) {
Write-Host "Direct Teams Phone assignment detected" -ForegroundColor Yellow
}
Write-Host ""
$Confirm = Read-Host "Type YES to remove Teams Phone access for this user"
if ($Confirm -ne "YES") {
Write-Host "Skipped — no changes made"
continue
}
# Remove from groups
foreach ($Gid in $GroupIds) {
Remove-MgGroupMemberByRef `
-GroupId $Gid `
-DirectoryObjectId $User.Id
Write-Host "✅ Removed from group $Gid" -ForegroundColor Green
}
# Remove direct license if present
if ($Direct) {
Set-MgUserLicense `
-UserId $User.Id `
-RemoveLicenses @($TeamsPhoneSkuId) `
-AddLicenses @()
Write-Host "✅ Direct Teams Phone license removed" -ForegroundColor Green
}
}
Disconnect-MgGraph
Usage Example: To remove stuck licenses from a user, run the script with the parameter:
.\Remove-License.ps1 -UserPrincipalName "user@domain.com"