~/2025/12/03/powershell-active-directory-report-group-membership-sprawl.md

PowerShell: Active Directory – Report Group Membership Sprawl

---
author: 
date: 
read: 5 min
in:   [ps, scripts]
tags: [powershell, active-directory, reporting]
---

$ grep -n '^#' post.md

Every Active Directory environment I have ever inherited has the same problem lurking in it: security groups nobody remembers creating, nested three or four levels deep, granting access that nobody can explain in an access review. This script doesn't fix group sprawl, but it produces the report you need to start the conversation: every security group ranked by how deep its nesting goes, how many accounts it really reaches once nesting is expanded, whether anyone owns it, and whether it has been touched in the last year.

Sprawl isn't only an audit problem. Every group a user belongs to, directly or through nesting, adds a SID to the user's Kerberos ticket, and Microsoft documents the resulting failure mode: users in too many groups get HTTP 400 - Bad Request (Request Header too long) from IIS-hosted apps, have trouble reaching resources, and may not get Group Policy updates. The default MaxTokenSize is 48,000 bytes on Windows Server 2012 / Windows 8 and later, and there is a separate hard limit of 1,010 group SIDs in an access token. Deep nesting is how ordinary users drift toward those limits without anyone adding them to hundreds of groups by hand, so the Usage section below also includes a per-user token size estimate.

Requirements

  • Windows PowerShell 5.1 or PowerShell 7 on Windows, with the ActiveDirectory module (RSAT).
  • An account that can read group objects and their member, memberOf, managedBy and whenChanged attributes across the domain.
  • A DC running Active Directory Web Services, which is what the module talks to.

Parameters

NameTypeRequiredDescription
SearchBaseStringNoDistinguished name of the OU whose groups are reported. Defaults to the whole domain. Nesting is always resolved against every group in the domain, so a group outside the OU still counts toward depth.
StaleDaysIntNoGroups whose whenChanged is older than this many days are flagged as stale. Defaults to 365.
MaxNestingDepthIntNoNesting depth at or above which a group is flagged as deeply nested. Defaults to 3.
IncludeDistributionGroupsSwitchNoAlso report distribution groups. By default only security groups are reported, since only they end up in access tokens.
OutputPathStringNoCSV path to export the full report to. If omitted, the report only prints to the console.
ServerStringNoDomain controller to run every query against. Defaults to whichever DC the module discovers.

Usage

Report on the whole domain with default thresholds:

powershell
.\Get-GroupSprawlReport.ps1

Scope to one OU, flag anything untouched in two years, and export for a quarterly access review:

powershell
.\Get-GroupSprawlReport.ps1 -SearchBase 'OU=Groups,DC=corp,DC=example,DC=com' -StaleDays 730 -OutputPath 'C:\Reports\group-sprawl.csv'

Sample output, sorted by nesting depth and then transitive member count:

text
Loaded 1,842 groups from DC=corp,DC=example,DC=com. Reporting 1,317 group(s) under OU=Groups,DC=corp,DC=example,DC=com.

Name                      Scope       NestingDepth ChildGroups DirectMembers TransitiveMembers DaysSinceChanged HasManagedBy Flags
----                      -----       ------------ ----------- ------------- ----------------- ---------------- ------------ -----
GG-AllStaff-Legacy        Global                 5           7             7              2210              612 False        Stale,DeeplyNested,NoManagedBy
DL-FS01-Projects-Modify   DomainLocal            4           3             3               842               45 True         DeeplyNested
GG-Finance-Contractors    Global                 3           2            14                63             1094 False        Stale,DeeplyNested,NoManagedBy
UG-App-Reporting          Universal              2           1             1                 0              401 False        Stale,Empty,NoManagedBy
GG-Loop-A                 Global                 1           1             1                12              220 True         Circular

Find the groups that most often sit in the middle of chains (the ones worth flattening first) from the exported CSV:

powershell
Import-Csv -Path 'C:\Reports\group-sprawl.csv' |
    Where-Object { [int]$_.NestedInCount -gt 0 -and [int]$_.ChildGroups -gt 0 } |
    Sort-Object -Property { [int]$_.NestedInCount } -Descending |
    Select-Object -First 20 -Property Name, NestedInCount, ChildGroups, NestingDepth, TransitiveMembers

Estimate one user's Kerberos token size with Microsoft's formula, TokenSize = 1200 + 40d + 8s, where, on Windows Server 2012 and later, d counts universal groups outside the user's account domain plus the SIDs in sIDHistory, and s counts universal groups inside the account domain plus every global and domain-local group. The snippet sorts SIDs by domain prefix, which lines up with those classes for tokenGroups: the only SIDs from other domains it carries are universal groups (global groups can't hold members from another domain, and other domains' domain-local groups are added by the resource domain, not stored here), while built-in S-1-5-32- groups are domain-local and count as s. It reads the computed tokenGroups attribute, which Microsoft warns is expensive for DCs, so run it for the handful of users you're investigating, not in a loop over the whole directory:

powershell
$user = Get-ADUser -Identity '<samaccountname>' -Properties tokenGroups, sIDHistory
$domainSid = (Get-ADDomain).DomainSID.Value
$groupSids = @($user.tokenGroups | Where-Object { $_ })
$historySids = @($user.sIDHistory | Where-Object { $_ })
$sameDomain = @($groupSids | Where-Object { $_.Value.StartsWith("$domainSid-") -or $_.Value.StartsWith("S-1-5-32-") }).Count
$otherDomain = ($groupSids.Count - $sameDomain) + $historySids.Count
[PSCustomObject]@{
    User            = $user.SamAccountName
    GroupSids       = $groupSids.Count
    EstimatedBytes  = 1200 + (40 * $otherDomain) + (8 * $sameDomain)
    DefaultMaxBytes = 48000
}
text
User     GroupSids EstimatedBytes DefaultMaxBytes
----     --------- -------------- ---------------
<user>         214           2912           48000

Script

powershell
<#
.SYNOPSIS
    Reports Active Directory groups by nesting depth, transitive membership, ownership and staleness.
.DESCRIPTION
    Loads every group in the domain once (member, memberOf, managedBy, whenChanged) and builds an
    in-memory nesting graph, so nesting depth is calculated without one directory round trip per
    child group and nesting that crosses the SearchBase boundary is still followed. For each group
    under SearchBase it then counts transitive non-group members with a single server-side
    LDAP_MATCHING_RULE_IN_CHAIN query instead of repeated Get-ADGroupMember -Recursive calls. Groups are
    flagged as Empty, Stale, DeeplyNested, NoManagedBy or Circular. Intended as the worklist for an access
    review, not an automatic cleanup.
.PARAMETER SearchBase
    Distinguished name of the OU whose groups are reported. Defaults to the whole domain.
.PARAMETER StaleDays
    Groups whose whenChanged is older than this many days are flagged as stale.
.PARAMETER MaxNestingDepth
    Nesting depth at or above which a group is flagged as deeply nested.
.PARAMETER IncludeDistributionGroups
    Also report distribution groups. By default only security groups are reported.
.PARAMETER OutputPath
    CSV path to export the full report to. If omitted, the report only prints to the console.
.PARAMETER Server
    Domain controller to run every query against. Defaults to whichever DC the module discovers.
.EXAMPLE
    .\Get-GroupSprawlReport.ps1 -SearchBase 'OU=Groups,DC=corp,DC=example,DC=com' -StaleDays 730
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2025-12-03)
    Requires: ActiveDirectory PowerShell module (RSAT)
#>
[CmdletBinding()]
param (
    [Parameter(Mandatory = $false)]
    [string]$SearchBase,

    [Parameter(Mandatory = $false)]
    [int]$StaleDays = 365,

    [Parameter(Mandatory = $false)]
    [int]$MaxNestingDepth = 3,

    [Parameter(Mandatory = $false)]
    [switch]$IncludeDistributionGroups,

    [Parameter(Mandatory = $false)]
    [string]$OutputPath,

    [Parameter(Mandatory = $false)]
    [string]$Server
)

Import-Module ActiveDirectory -ErrorAction Stop

# Splatted into every AD query so a -Server value pins the whole run to one DC.
$adParams = @{}
if ($Server) {
    $adParams['Server'] = $Server
}

$domainDN = (Get-ADDomain @adParams).DistinguishedName
if (-not $SearchBase) {
    $SearchBase = $domainDN
}

# Load every group in the domain once; nesting is resolved from this map, not from repeated queries.
$allGroups = Get-ADGroup -Filter * -SearchBase $domainDN -Properties member, memberOf, managedBy, whenChanged @adParams
$groupsByDN = @{}
foreach ($group in $allGroups) {
    $groupsByDN[$group.DistinguishedName] = $group
}

$depthCache = @{}
$circular = New-Object -TypeName 'System.Collections.Generic.HashSet[string]'

function Get-NestingDepth {
    param (
        [Parameter(Mandatory = $true)]
        [string]$DistinguishedName,

        [Parameter(Mandatory = $true)]
        [System.Collections.Generic.HashSet[string]]$Path
    )

    if ($depthCache.ContainsKey($DistinguishedName)) {
        return $depthCache[$DistinguishedName]
    }

    # Already on the current path: a circular nest. Record it and stop descending.
    if (-not $Path.Add($DistinguishedName)) {
        [void]$circular.Add($DistinguishedName)
        return 0
    }

    $maxDepth = 0
    foreach ($memberDN in $groupsByDN[$DistinguishedName].member) {
        if ($groupsByDN.ContainsKey($memberDN)) {
            $childDepth = 1 + (Get-NestingDepth -DistinguishedName $memberDN -Path $Path)
            if ($childDepth -gt $maxDepth) {
                $maxDepth = $childDepth
            }
        }
    }

    [void]$Path.Remove($DistinguishedName)
    $depthCache[$DistinguishedName] = $maxDepth
    return $maxDepth
}

function Get-ValueCount {
    param ($Value)

    # An unset attribute can come back as $null, and @($null).Count is 1, not 0.
    if ($null -eq $Value) {
        return 0
    }
    return @($Value).Count
}

function ConvertTo-LdapFilterValue {
    param ([string]$Value)

    # RFC 4515 escaping; the backslash must be replaced first.
    return $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29').Replace([string][char]0, '\00')
}

$scopeSuffix = ',' + $SearchBase
$reportGroups = @($allGroups | Where-Object {
    ($_.DistinguishedName -eq $SearchBase -or $_.DistinguishedName.EndsWith($scopeSuffix, [System.StringComparison]::OrdinalIgnoreCase)) -and
    ($IncludeDistributionGroups -or $_.GroupCategory -eq 'Security')
})

Write-Host ("Loaded {0:N0} groups from {1}. Reporting {2:N0} group(s) under {3}." -f $allGroups.Count, $domainDN, $reportGroups.Count, $SearchBase)

$now = Get-Date
$cutoffDate = $now.AddDays(-$StaleDays)
$index = 0

$report = foreach ($group in $reportGroups) {
    $index++
    Write-Progress -Activity 'Analysing groups' -Status $group.Name -PercentComplete (($index / $reportGroups.Count) * 100)

    $depth = Get-NestingDepth -DistinguishedName $group.DistinguishedName -Path (New-Object -TypeName 'System.Collections.Generic.HashSet[string]')
    $childGroups = @($group.member | Where-Object { $_ -and $groupsByDN.ContainsKey($_) }).Count
    $directMembers = Get-ValueCount -Value $group.member

    # Every non-group object that is a member at any depth, counted on the DC in one query.
    $chainFilter = '(&(!(objectClass=group))(memberOf:1.2.840.113556.1.4.1941:={0}))' -f (ConvertTo-LdapFilterValue -Value $group.DistinguishedName)
    $transitive = @(Get-ADObject -LDAPFilter $chainFilter -SearchBase $domainDN @adParams).Count

    $flags = New-Object -TypeName System.Collections.Generic.List[string]
    if ($group.whenChanged -lt $cutoffDate) { $flags.Add('Stale') }
    if ($transitive -eq 0) { $flags.Add('Empty') }
    if ($depth -ge $MaxNestingDepth) { $flags.Add('DeeplyNested') }
    if (-not $group.managedBy) { $flags.Add('NoManagedBy') }
    if ($circular.Contains($group.DistinguishedName)) { $flags.Add('Circular') }

    [PSCustomObject]@{
        Name              = $group.Name
        Scope             = $group.GroupScope
        Category          = $group.GroupCategory
        NestingDepth      = $depth
        ChildGroups       = $childGroups
        DirectMembers     = $directMembers
        TransitiveMembers = $transitive
        NestedInCount     = Get-ValueCount -Value $group.memberOf
        DaysSinceChanged  = [int]($now - $group.whenChanged).TotalDays
        HasManagedBy      = [bool]$group.managedBy
        Flags             = $flags -join ','
        DistinguishedName = $group.DistinguishedName
    }
}

Write-Progress -Activity 'Analysing groups' -Completed

$sortedReport = $report | Sort-Object -Property @{ Expression = 'NestingDepth'; Descending = $true }, @{ Expression = 'TransitiveMembers'; Descending = $true }

$sortedReport |
    Select-Object -First 50 -Property Name, Scope, NestingDepth, ChildGroups, DirectMembers, TransitiveMembers, DaysSinceChanged, HasManagedBy, Flags |
    Format-Table -AutoSize

if ($OutputPath) {
    $sortedReport | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Host "Full report ($($report.Count) rows) exported to $OutputPath"
}

Notes

  • Why not Get-ADGroupMember -Recursive? The first draft of this script used it per group, plus a recursive depth walk that called Get-ADGroupMember again for every child group, so a domain with thousands of groups turned into tens of thousands of Web Services round trips, many of them expanding the same subtrees over and over. Loading the groups once and asking the DC for transitive members with LDAP_MATCHING_RULE_IN_CHAIN (1.2.840.113556.1.4.1941) replaces all of that with one bulk read plus one paged query per reported group. It also means nested groups are never counted as members, only the accounts they reach. Microsoft documents that rule as walking "the chain of ancestry in objects all the way to the root", and warns that high fan-out chain queries are processor-intensive for the DC, so run the report off-hours on a very large domain.
  • Transitive member counts miss two things. A user's primary group is recorded on the user as a RID in primaryGroupID (Domain Users by default), not as a value in the group's member attribute, so Domain Users and Domain Computers will look nearly empty. And the chain query only searches this domain's naming context: members from other domains in the same forest aren't counted, while members from external or forest trusts are, because they're stored here as foreign security principals. Treat those as known exceptions when reading the numbers.
  • whenChanged is not replicated: each DC keeps its own value, and it moves whenever any attribute on the group changes, not only membership. It's a coarse staleness signal. If two runs disagree, pin the queries to one DC with the script's -Server parameter.
  • NoManagedBy checks managedBy, which is the only field AD has natively for a group's accountable owner. (The object's security descriptor also records an owner, but that's the account that created it, usually an admin or Domain Admins, not someone who answers for the membership.) An empty managedBy doesn't mean nobody owns the group, but it does mean nobody can be asked from the directory alone, and that is the question an access review keeps running into.
  • The depth cache makes the walk linear in the number of groups. In a circular nest the cached depth for the groups inside the loop is a lower bound, not exact, which is fine: a Circular flag already means someone needs to look at it.
  • The Empty flag catches abandoned groups but is not itself a reason to delete one. Some groups are intentionally empty scaffolding for a process that has not started yet, and some grant rights through ACLs or GPO security filtering that are still referenced. Treat the report as a worklist for a human, not an automatic prune.
  • I run this quarterly rather than continuously. Group sprawl is a slow-moving problem, and the CSV is more useful compared release over release (is maximum nesting getting worse, are the same stale groups still stale) than as a real-time dashboard.
  • If the token estimate for a user comes out near the default MaxTokenSize, Microsoft's documented resolution is raising MaxTokenSize in the registry on every computer involved in the authentication. The same article explains why that has a ceiling: IIS uses a 64 KB request buffer and the ticket is Base64-encoded in HTTP (133 percent of its size), which is where the 48,000-byte default comes from, and values above 65,535 break other protocols. In my experience, reducing the group count by flattening nests is the fix that keeps working. The estimate is also a rough one: it ignores resource SID compression, claims and delegation, which the article covers.

Source