The Problem#
Every Intune environment I’ve worked in has the same disease: groups that were created for a purpose, assigned to something, and then forgotten when the policy was deleted or reassigned. Over time, you end up with dozens – sometimes hundreds – of groups prefixed with “Intune” that are not assigned to anything. They clutter your group list, confuse new team members, and make it difficult to tell which groups are load-bearing and which are dead weight.
The Intune portal doesn’t give you a view that answers “which of my groups are not used by any assignment?” So you script it.
How It Works#
The script does one thing: finds the groups that look like they belong to Intune but aren’t actually assigned to anything. It pulls all groups matching your naming convention from Graph, collects every assignment across every Intune workload, and cross-references. What’s left is your cleanup list.
# Default -- finds all groups starting with "Intune"
.\Get-AllIntuneGroupsWithoutAssignments.ps1
# Custom prefix -- adjust to your naming convention
.\Get-AllIntuneGroupsWithoutAssignments.ps1 -Prefix "MDM"
# Target a specific tenant
.\Get-AllIntuneGroupsWithoutAssignments.ps1 -TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Export to a specific folder without grid view
.\Get-AllIntuneGroupsWithoutAssignments.ps1 -ExportPath "C:\Reports" -NoGridViewThe parent group check matters. If a group is nested inside another group that IS assigned to a policy, then the nested group is technically still in use. The script accounts for that – a group only lands in the report if it has no direct assignment AND none of its parent groups are assigned either. This two-layer check prevents false positives that could lead to deleting something that’s indirectly active.
Prerequisites#
- PowerShell 5.1+ with the Microsoft Graph PowerShell SDK
Microsoft.Graph.Authentication(forConnect-MgGraphandInvoke-MgGraphRequest)ImportExcelmodule (optional – falls back to CSV if not installed)- Permissions (delegated):
Group.Read.All,DeviceManagementApps.Read.All,DeviceManagementConfiguration.Read.All,DeviceManagementServiceConfig.Read.All,DeviceManagementManagedDevices.Read.All,DeviceManagementScripts.Read.All - Your account also needs an Intune RBAC role with read access to the workloads being queried
That’s a lot of read permissions. The script queries every Intune workload – device configurations, compliance policies, Settings Catalog, endpoint security, apps, scripts, remediations, Autopilot profiles, update rings, enrollment configurations, app protection policies, and more. You need read access to all of them to get a complete picture of which groups are actually in use.
Implementation#
Collecting All Assignments#
This is the part the naive version of this script gets wrong. You can’t just check one or two workloads – a group might be unused by device configurations but assigned to an app or a compliance policy. The script queries 16 workload types plus app protection policies:
$workloads = @(
@{ Label = 'Device Configurations'; ListUri = '...deviceConfigurations?$select=id'; Template = '...deviceConfigurations/{id}/assignments' }
@{ Label = 'Compliance Policies'; ListUri = '...deviceCompliancePolicies?$select=id'; Template = '...deviceCompliancePolicies/{id}/assignments' }
@{ Label = 'Settings Catalog'; ListUri = '...configurationPolicies?$select=id'; Template = '...configurationPolicies/{id}/assignments' }
@{ Label = 'Mobile Apps'; ListUri = '...mobileApps?$select=id'; Template = '...mobileApps/{id}/assignments' }
# ... 12 more workloads
)For each workload, it lists all policies/apps, then fetches the assignments for each one. Every group ID that appears in any assignment goes into a HashSet for O(1) lookups later:
$assignedGroupIds = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase)
foreach ($item in $items) {
$assignments = Invoke-GraphGetPaged -Uri ($template -replace '\{id\}', $item.id)
foreach ($assignment in $assignments) {
$groupId = $assignment.target.groupId
if ($groupId) { [void]$assignedGroupIds.Add($groupId) }
}
}This generates a lot of API calls. In a tenant with hundreds of policies and apps, expect a few minutes of runtime. The script handles Graph throttling (429) with exponential backoff – if you hit rate limits, it waits and retries automatically up to 5 times per call.
Filtering Groups by Prefix#
The Graph query uses startswith to filter groups server-side:
$AllPrefixGroups = Invoke-GraphGetPaged -Uri `
"https://graph.microsoft.com/v1.0/groups?`$filter=startswith(displayName,'$Prefix')&`$select=id,displayName"This is a server-side filter, not client-side – important when you have thousands of groups. The -Prefix parameter defaults to "Intune" but you can set it to whatever naming convention your tenant uses. $select=id,displayName keeps the response payload minimal.
The Parent Group Check#
A group with no direct assignment might still be in use if it’s nested inside a group that IS assigned. For each unassigned group, the script checks memberOf:
foreach ($group in $unassignedGroups) {
$memberOf = Invoke-GraphGetPaged -Uri `
"https://graph.microsoft.com/v1.0/groups/$($group.id)/memberOf?`$select=id,displayName"
$parentAssigned = $false
foreach ($parent in $memberOf) {
if ($assignedGroupIds.Contains($parent.id)) {
$parentAssigned = $true
break
}
}
if (-not $parentAssigned) {
# Truly unassigned -- add to report
}
}If any parent group is in the assigned set, the group is skipped. Only groups with no direct assignment AND no indirectly assigned parent land in the report.
This is one memberOf call per unassigned group. If you have 500 groups that pass the first filter, that’s 500 additional API calls. In large tenants, Graph will throttle you before you finish. The built-in retry logic handles this, but expect the parent check phase to take longer than the initial assignment collection.
Output#
Results go to Out-GridView for quick visual inspection (unless -NoGridView is set) and to an Excel file. If ImportExcel isn’t installed, it falls back to CSV:
.\Get-AllIntuneGroupsWithoutAssignments.ps1 -ExportPath "C:\Reports"The export includes GroupName, GroupID, and MemberOf (the parent groups, if any – useful for understanding why the group exists even if it’s not assigned).
Security Considerations#
- The script is read-only – it doesn’t delete anything. You review the report and make manual decisions.
- The permission set is broad – six read scopes across all Intune workloads. This is necessary to get a complete picture, but be deliberate about who gets this access. In a delegated context, it runs under your identity.
- All Graph calls use the
betaendpoint for Intune workloads. Thebetaendpoint can change without notice. The group queries usev1.0where possible.
When to Use This / When Not To#
Use this when you suspect your Intune group inventory has drifted from reality, when onboarding a new team member who asks “what are all these groups for,” or as part of a quarterly governance review.
Run it quarterly, or after any major policy reorganization. Export the results and actually give them to someone with authority to delete groups. A report nobody acts on is just more clutter.
Don’t use this as an automated cleanup tool. The output is a report, not a deletion script, and that’s intentional. Groups that look unused might be staged for a future deployment or used by automation you don’t know about. Always review before deleting.
The startswith filter means you need a consistent naming convention. If you don’t have one, this is a good reason to start.