Short answer: run .\Get-ADNestedGroupMembership.ps1 -Identity bob to see every group a user belongs to, directly or through nesting, as an indented tree with the path to each group. Point it at a group (-Identity "Domain Admins") to expand everything inside it instead. Circular nesting is detected and marked as a loop, the primary group is included, and -ExportCsv saves the full tree with paths.
We ran this script on 6 October 2026 on a Windows Server 2025 Standard domain controller (build 26100.33438, September 2026 update, Windows PowerShell 5.1) and a Windows 11 Pro member PC in our lab domain contoso.com. The example output below is from that run; the lab domain is small and new, so your numbers will be bigger. It was also syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25.
Table of Contents
What it does
“Why can bob open the Finance share?” is usually answered three groups deep. Get-ADPrincipalGroupMembership shows only direct groups, and Get-ADGroupMember -Recursive flattens a group to its users without saying how they got there. This read-only script keeps the structure:
- Two directions. MemberOf walks up from a user, computer or group through
memberOf. Members walks down from a group throughmember.-Direction Auto(the default) picks MemberOf for users and computers and Members for groups. - Path for every row. e.g.
bob > Finance-RW > Finance-All > All-Staff, so you can see exactly which link to remove. - Loop detection. If a group appears again inside its own chain (A contains B, B contains A) the row is marked Loop and not followed again.
- No duplicate expansion. A group reached through a second branch is listed with Already listed and not expanded twice, which keeps big trees fast and readable.
- Primary group. In MemberOf mode the primary group (normally Domain Users or Domain Computers) is added from
primaryGroupID, because it is not stored inmemberOf. - Group type shown. Global, DomainLocal, Universal or Builtin, and Security or Distribution, decoded from
groupType. - Forest-aware. Objects in other domains of the forest are read from their own domain, worked out from the DN.
Requirements
- Windows PowerShell 5.1 or PowerShell 7 on Windows, with the ActiveDirectory module (RSAT).
- Read access to the user and group objects (the default for domain users).
- Network access to a DC in each domain that appears in the tree.
Download and first run
- Copy the script from the box on this page (or use the download button) and save it as
C:\Scripts\Get-ADNestedGroupMembership.ps1. - If you downloaded the file, remove the “downloaded from the internet” mark so the execution policy lets it run:
Unblock-File C:\Scripts\Get-ADNestedGroupMembership.ps1. - Run it on a domain-joined machine that has the ActiveDirectory module: a domain controller, a management server, or a Windows 11 PC with RSAT (see Install RSAT on Windows 11).
- Read the built-in help once, then run it with no options to see the result on screen before you export anything.
cd C:\Scripts
Get-Help .\Get-ADNestedGroupMembership.ps1 -Full
.\Get-ADNestedGroupMembership.ps1 -Identity bob
Options
| Parameter | What it does | Default |
|---|---|---|
-Identity | Starting object: sAMAccountName, DN, SID or GUID. Computers need the trailing $ (PC01$) | Required |
-Direction | Auto, MemberOf or Members | Auto |
-MaxDepth | Stop expanding below this depth (1 to 100) | 25 |
-GroupsOnly | In Members mode, list only groups, not users and computers | Off |
-Server | Domain or DC used to find the starting object | Current domain |
-ExportCsv | Write all rows with paths to CSV | Not written |
-PassThru | Return the row objects instead of the printed tree | Off |
Usage examples
# Every group bob is in, as a tree
.\Get-ADNestedGroupMembership.ps1 -Identity bob
# Everything inside Domain Admins, saved with paths
.\Get-ADNestedGroupMembership.ps1 -Identity "Domain Admins" -ExportCsv C:\Reports\da-tree.csv
# Which groups is this group nested in?
.\Get-ADNestedGroupMembership.ps1 -Identity "Finance-RW" -Direction MemberOf
# Only the group structure under a big group
.\Get-ADNestedGroupMembership.ps1 -Identity "All-Staff" -GroupsOnly
# Find circular nesting starting from a user
.\Get-ADNestedGroupMembership.ps1 -Identity bob -PassThru | Where-Object Note -eq 'Loop'
# Does bob get into Finance-RW, and how?
.\Get-ADNestedGroupMembership.ps1 -Identity bob -PassThru | Where-Object Name -eq 'Finance-RW' | Select-Object Path
Output and CSV columns
On screen you get a one-line summary (unique groups, rows, loops) followed by an indented tree: one line per object with its type, and the note in brackets where there is one. The example output further down is from our lab run.
| Column | Meaning |
|---|---|
| Level | Depth from the starting object (1 = direct) |
| Name, SamAccountName | The object found |
| ObjectClass | group, user, computer, foreignSecurityPrincipal … |
| GroupType | e.g. “Global Security”, “Universal Distribution” (groups only) |
| Path | Chain from the starting object to this one, separated by ” > “ |
| Note | Empty, Loop, Already listed, Primary group or MaxDepth reached |
| Domain, DistinguishedName | Where the object lives |
Example output
.\Get-ADNestedGroupMembership.ps1 -Identity bob walks up from a user, and -Identity All-Staff -Direction Members walks down from a group. Both from the lab:
Bob Smith (user) - MemberOf: 7 unique group(s), 7 row(s), 0 loop(s)
Helpdesk (Global Security)
IT-Admins (Global Security)
All-Staff (Global Security)
Server-Admins (Global Security)
Administrators (DomainLocal Security)
Domain Users (Global Security) [Primary group]
Users (DomainLocal Security)
All-Staff (group) - Members: 4 unique group(s), 9 row(s), 0 loop(s)
Finance (Global Security)
Carol White (user)
IT-Admins (Global Security)
Alice Jones (user)
Helpdesk (Global Security)
Bob Smith (user)
Sales (Global Security)
Dave Brown (user)
Erin Green (user)
The first tree shows why nesting matters: bob is only in Helpdesk directly, but through three nested groups he ends up in the built-in Administrators group of the domain.
Schedule it
This is mostly an on-demand troubleshooting tool, but a weekly snapshot of the structure under your sensitive groups makes changes easy to spot: compare this week’s CSV with last week’s.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Get-ADNestedGroupMembership.ps1 -Identity "Domain Admins" -ExportCsv C:\Reports\da-tree.csv'
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 7am
$principal = New-ScheduledTaskPrincipal -UserId 'CONTOSO\gmsa-adreport$' -LogonType Password
Register-ScheduledTask -TaskName 'AD nested groups: Domain Admins' -Action $action -Trigger $trigger -Principal $principal
# Later: what changed since the previous copy?
Compare-Object (Import-Csv C:\Reports\da-tree-old.csv) (Import-Csv C:\Reports\da-tree.csv) -Property DistinguishedName, Path
The task needs a gMSA that this server may use (see our gMSA guide). Rename or copy the previous CSV before each run if you want to compare.
How it works
- The starting object is read with
Get-ADObject. If the identity is not a DN or GUID, the script searches(sAMAccountName=...)and then(objectSid=...), with LDAP special characters escaped. - A recursive function reads
memberOformemberof the current object, sorted by DN, and looks each link up withGet-ADObjectagainst the domain named in its DN. Every lookup is cached. - The function carries the list of ancestors on the current branch. A link that is already in that list is a loop; a group already expanded on another branch is “Already listed”.
- For users and computers the primary group SID is built from the account’s domain SID and
primaryGroupID, then resolved withGet-ADGroupand expanded like any other group. - Rows are printed as a tree or returned and exported.
Limitations
- memberOf only reflects groups whose membership the queried DC stores. Groups in other domains can be missing: universal group membership is available from a global catalog, and domain local groups of other domains never appear in
memberOfon your DC. For cross-domain access, check on the resource side as well. - Primary group of nested groups. Only the starting account’s primary group is added; accounts inside a group whose primary group is that group are not listed in Members mode.
- Very large groups (thousands of direct members) have not been tested with this script yet.
- Effective access also depends on deny entries and on rights granted outside groups; this script shows membership only.
- Not yet run against a live domain (see the note at the top).
Official documentation: Get-ADObject (Microsoft Learn) · Group-Type attribute (groupType flags) · Security identifiers (well-known SIDs and RIDs)
Related: Get-ADUser PowerShell Examples: 25 Queries for Active Directory · Group Policy Security Filtering: 6 Ways to Target or Exclude · NTFS Permissions and Share Permissions: 7 Secure File Server Rules · Local Administrators Group Policy: 4 Ways to Control Admin Rights · Install RSAT on Windows 11: 5 Methods, Including Offline
See also: Export AD Users to CSV: PowerShell Script with Last Logon · Locked Out AD Users Report: PowerShell Script with Lockout Source · AD Privileged Group Report: Domain Admins and adminCount Audit · Inactive AD Accounts Report: PowerShell Script with Safe Disable · AD Health Check Report: dcdiag and repadmin PowerShell Script
The script
# AD Nested Group Membership: PowerShell Tree with Loop Detection (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/ad-nested-group-membership/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
Show the full nested group membership tree of an AD user, computer or group, with the path to each group
and loop detection.
.DESCRIPTION
Read-only. Two directions:
MemberOf every group the object belongs to, directly or through nesting (walks memberOf upwards).
For users and computers the primary group (primaryGroupID, usually Domain Users or Domain
Computers) is added, because it is not stored in memberOf.
Members everything inside a group, expanded through nested groups (walks member downwards).
Default (Auto): MemberOf for users and computers, Members for groups.
Each row has the depth (Level), the object, and the Path from the starting object, e.g.
"bob > Sales-RW > Sales-All > All-Staff". Circular nesting (A in B, B in A) is reported with
Note = "Loop" and not followed again. A group reached a second time through another branch is reported
with Note = "Already listed" and is not expanded twice, which keeps large trees fast. Objects in other
domains of the forest are read from that domain (worked out from the DN).
.PARAMETER Identity
sAMAccountName, distinguished name, SID or GUID of the starting user, computer or group.
For a computer, use its sAMAccountName with the trailing $, e.g. PC01$.
.PARAMETER Direction
Auto (default), MemberOf or Members.
.PARAMETER MaxDepth
Stop expanding below this depth (default 25).
.PARAMETER GroupsOnly
In Members mode, only list groups (skip users, computers and other objects).
.PARAMETER Server
Domain or DC to query for the starting object (default: current domain).
.PARAMETER ExportCsv
Write the rows to this CSV file (UTF-8).
.PARAMETER PassThru
Output the row objects to the pipeline instead of the indented tree.
.EXAMPLE
.\Get-ADNestedGroupMembership.ps1 -Identity bob
.EXAMPLE
.\Get-ADNestedGroupMembership.ps1 -Identity "Domain Admins" -ExportCsv C:\Reports\da-tree.csv
.EXAMPLE
.\Get-ADNestedGroupMembership.ps1 -Identity "Sales-All" -Direction MemberOf
.EXAMPLE
.\Get-ADNestedGroupMembership.ps1 -Identity bob -PassThru | Where-Object Note -eq 'Loop'
.NOTES
Name: Get-ADNestedGroupMembership.ps1
Version: 1.0.0
Source: https://srvscripts.com/scripts/ad-nested-group-membership/
License: MIT
Requires: Windows PowerShell 5.1 or PowerShell 7 on Windows, ActiveDirectory module (RSAT), read access to AD.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[ValidateNotNullOrEmpty()]
[string]$Identity,
[ValidateSet('Auto', 'MemberOf', 'Members')]
[string]$Direction = 'Auto',
[ValidateRange(1, 100)]
[int]$MaxDepth = 25,
[switch]$GroupsOnly,
[ValidateNotNullOrEmpty()]
[string]$Server,
[ValidateNotNullOrEmpty()]
[string]$ExportCsv,
[switch]$PassThru
)
Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
throw "The ActiveDirectory module is not installed. Install RSAT: Active Directory Domain Services tools, then run again."
}
Import-Module ActiveDirectory -Verbose:$false
$props = 'member', 'memberOf', 'objectClass', 'objectSid', 'sAMAccountName', 'groupType', 'primaryGroupID', 'name'
function Get-AttributeValue {
param($Entity, [string]$Name)
if ($Entity.PropertyNames -contains $Name) { return $Entity[$Name].Value }
$null
}
function Get-DomainFromDN {
param([string]$DistinguishedName)
$parts = [regex]::Matches($DistinguishedName, '(?i)(?:^|,)DC=([^,]+)') | ForEach-Object { $_.Groups[1].Value }
($parts -join '.')
}
$cache = @{}
function Get-CachedObject {
param([string]$DistinguishedName)
if ($cache.ContainsKey($DistinguishedName)) { return $cache[$DistinguishedName] }
$o = $null
try {
$o = Get-ADObject -Identity $DistinguishedName -Server (Get-DomainFromDN $DistinguishedName) -Properties $props
} catch {
Write-Warning "Cannot read '$DistinguishedName': $($_.Exception.Message)"
}
$cache[$DistinguishedName] = $o
$o
}
function Get-GroupKind {
param($Object)
$gt = Get-AttributeValue $Object 'groupType'
if ($null -eq $gt) { return '' }
$v = [int64]$gt
$scope = if ($v -band 0x2) { 'Global' } elseif ($v -band 0x4) { 'DomainLocal' } elseif ($v -band 0x8) { 'Universal' } elseif ($v -band 0x1) { 'Builtin' } else { '' }
$cat = if ($v -band 0x80000000) { 'Security' } else { 'Distribution' }
"$scope $cat".Trim()
}
function Get-LastClass {
param($Object)
$c = Get-AttributeValue $Object 'objectClass'
if ($null -eq $c) { return [string]$Object.ObjectClass }
[string](@($c)[-1])
}
# ---- Starting object ------------------------------------------------------------------------------------
$domArgs = @{}
if ($Server) { $domArgs.Server = $Server }
$domain = Get-ADDomain @domArgs
$startArgs = @{ Server = $domain.DNSRoot; Properties = $props }
if ($Server) { $startArgs.Server = $Server }
$start = $null
try {
$start = Get-ADObject -Identity $Identity @startArgs
} catch {
# Not a DN or GUID: try sAMAccountName, then SID.
$esc = $Identity -replace '\\', '\5c' -replace '\*', '\2a' -replace '\(', '\28' -replace '\)', '\29'
$hit = @(Get-ADObject -LDAPFilter "(sAMAccountName=$esc)" @startArgs)
if (-not $hit.Count -and $Identity -match '^S-1-') { $hit = @(Get-ADObject -LDAPFilter "(objectSid=$Identity)" @startArgs) }
if (-not $hit.Count) { throw "No user, computer or group '$Identity' found in $($startArgs.Server). For a computer use NAME`$ (with the dollar sign)." }
$start = $hit[0]
}
$startClass = Get-LastClass $start
$cache[$start.DistinguishedName] = $start
$mode = $Direction
if ($mode -eq 'Auto') { $mode = if ($startClass -eq 'group') { 'Members' } else { 'MemberOf' } }
Write-Verbose "Start: $($start.DistinguishedName) ($startClass), direction $mode"
# ---- Walk -----------------------------------------------------------------------------------------------
$rows = [System.Collections.Generic.List[object]]::new()
$expanded = @{}
function Add-Row {
param($Object, [int]$Level, [string]$Path, [string]$Note)
$rows.Add([pscustomobject]@{
Level = $Level
Name = [string](Get-AttributeValue $Object 'name')
SamAccountName = [string](Get-AttributeValue $Object 'sAMAccountName')
ObjectClass = Get-LastClass $Object
GroupType = Get-GroupKind $Object
Path = $Path
Note = $Note
Domain = Get-DomainFromDN $Object.DistinguishedName
DistinguishedName = $Object.DistinguishedName
})
}
function Invoke-Walk {
param($Object, [int]$Level, [string]$Path, [string[]]$Ancestors, [int]$DepthLimit, [bool]$OnlyGroups)
if ($Level -gt $DepthLimit) { return }
$attr = if ($mode -eq 'MemberOf') { 'memberOf' } else { 'member' }
$links = @(Get-AttributeValue $Object $attr | Where-Object { $_ })
foreach ($dn in ($links | Sort-Object)) {
$o = Get-CachedObject ([string]$dn)
if (-not $o) { continue }
$cls = Get-LastClass $o
$name = [string](Get-AttributeValue $o 'name')
$p = "$Path > $name"
if ($Ancestors -contains $o.DistinguishedName) { Add-Row $o $Level $p 'Loop'; continue }
if ($cls -ne 'group') {
if (-not $OnlyGroups) { Add-Row $o $Level $p '' }
continue
}
if ($expanded.ContainsKey($o.DistinguishedName)) { Add-Row $o $Level $p 'Already listed'; continue }
$expanded[$o.DistinguishedName] = $true
$note = ''
if ($Level -eq $DepthLimit) { $note = 'MaxDepth reached' }
Add-Row $o $Level $p $note
Invoke-Walk -Object $o -Level ($Level + 1) -Path $p -Ancestors ($Ancestors + $o.DistinguishedName) -DepthLimit $DepthLimit -OnlyGroups $OnlyGroups
}
}
$startName = [string](Get-AttributeValue $start 'name')
Invoke-Walk -Object $start -Level 1 -Path $startName -Ancestors @($start.DistinguishedName) -DepthLimit $MaxDepth -OnlyGroups $GroupsOnly.IsPresent
# Primary group (MemberOf mode, users and computers)
if ($mode -eq 'MemberOf') {
$pgid = Get-AttributeValue $start 'primaryGroupID'
$sid = Get-AttributeValue $start 'objectSid'
if ($null -ne $pgid -and $null -ne $sid) {
$sidText = [string]$sid
if ($sid -is [System.Security.Principal.SecurityIdentifier]) { $sidText = $sid.Value }
$domainSid = $sidText -replace '-\d+$', ''
try {
$pg = Get-ADGroup -Identity "$domainSid-$pgid" -Server (Get-DomainFromDN $start.DistinguishedName) -Properties $props
$cache[$pg.DistinguishedName] = $pg
if (-not $expanded.ContainsKey($pg.DistinguishedName)) {
$expanded[$pg.DistinguishedName] = $true
Add-Row $pg 1 "$startName > $($pg.Name)" 'Primary group'
Invoke-Walk -Object $pg -Level 2 -Path "$startName > $($pg.Name)" -Ancestors @($start.DistinguishedName, $pg.DistinguishedName) -DepthLimit $MaxDepth -OnlyGroups $GroupsOnly.IsPresent
}
} catch {
Write-Verbose "Primary group $domainSid-$pgid not resolved: $($_.Exception.Message)"
}
}
}
$out = @($rows)
# ---- Output ---------------------------------------------------------------------------------------------
if ($ExportCsv) {
$out | Export-Csv -LiteralPath $ExportCsv -NoTypeInformation -Encoding UTF8
Write-Information ("{0} row(s) written to {1}" -f $out.Count, $ExportCsv) -InformationAction Continue
}
if ($PassThru) { return $out }
$groupCount = @($out | Where-Object { $_.ObjectClass -eq 'group' -and $_.Note -ne 'Already listed' -and $_.Note -ne 'Loop' }).Count
$loops = @($out | Where-Object { $_.Note -eq 'Loop' }).Count
Write-Information ("{0} ({1}) - {2}: {3} unique group(s), {4} row(s), {5} loop(s)" -f $startName, $startClass, $mode, $groupCount, $out.Count, $loops) -InformationAction Continue
foreach ($r in $out) {
$tag = ''
if ($r.Note) { $tag = " [$($r.Note)]" }
$kind = $r.ObjectClass
if ($r.GroupType) { $kind = $r.GroupType }
Write-Information ((' ' * $r.Level) + "$($r.Name) ($kind)$tag") -InformationAction Continue
}
e0d732ba39033f44195727f04997672cacee7f2b4440bbc7916d0860600016d6curl -fsSL -o Get-ADNestedGroupMembership.ps1 https://scr.srvscripts.com/ad-nested-group-membership/Get-ADNestedGroupMembership.ps1 && curl -fsSL https://scr.srvscripts.com/ad-nested-group-membership/Get-ADNestedGroupMembership.ps1.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/ad-nested-group-membership/Get-ADNestedGroupMembership.ps1' -OutFile 'Get-ADNestedGroupMembership.ps1'; if ((Get-FileHash 'Get-ADNestedGroupMembership.ps1' -Algorithm SHA256).Hash -eq 'E0D732BA39033F44195727F04997672CACEE7F2B4440BBC7916D0860600016D6') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
How do I see nested group membership for a user in PowerShell?
Run Get-ADNestedGroupMembership.ps1 -Identity followed by the user name. It follows memberOf through every level and shows the path to each group. Get-ADPrincipalGroupMembership alone shows only direct groups.
How do I list all members of a group including nested groups?
Run the script with the group name, for example -Identity “Domain Admins”. Get-ADGroupMember -Recursive also works but returns a flat list without the nesting path.
What is a circular group nesting loop?
Group A is a member of group B and B is, directly or further down, a member of A. AD allows it, but it makes troubleshooting confusing. The script marks the repeat with Note = Loop.
Why is Domain Users missing from memberOf?
The primary group is stored as primaryGroupID on the account, not in memberOf. The script adds it with the note Primary group.
How do I check a computer account?
Use its sAMAccountName with the dollar sign, for example -Identity PC01$, or its distinguished name.
Does the script change group membership?
No. It only reads Active Directory.