- Published on
How to Get a Full Group Membership Report in Active Directory (PowerShell Script)
A full access-control review doesn't ask about one group — it asks about every group. Checking them one at a time with Get-ADGroupMember works fine when you have three groups to look at; it doesn't scale to the couple hundred that accumulate in a typical mid-size AD over the years. What you actually want is one export covering every group and its members at once.
The quick answer
Get-ADGroup -Filter {GroupCategory -eq "Security"} | ForEach-Object {
$group = $_.Name
Get-ADGroupMember -Identity $group | Select-Object @{N='Group';E={$group}}, Name, objectClass
} | Export-Csv -Path .\group-membership.csv -NoTypeInformation
A more useful reporting script
#requires -Modules ActiveDirectory
<#
.SYNOPSIS
Exports a full group-to-member report for every group in Active Directory.
#>
[CmdletBinding()]
param(
[ValidateSet('Security', 'Distribution')]
[string]$GroupCategory,
[switch]$IncludeEmptyGroups,
[string]$SearchBase,
[string]$OutputCsv
)
Import-Module ActiveDirectory -ErrorAction Stop
$groupParams = @{ Filter = '*'; Properties = @('GroupCategory', 'GroupScope', 'member') }
if ($SearchBase) { $groupParams['SearchBase'] = $SearchBase }
$groups = Get-ADGroup @groupParams
if ($GroupCategory) { $groups = $groups | Where-Object { $_.GroupCategory -eq $GroupCategory } }
$results = New-Object System.Collections.Generic.List[object]
foreach ($group in $groups) {
try {
$members = Get-ADGroupMember -Identity $group -ErrorAction Stop
} catch {
Write-Warning "Could not read members of '$($group.Name)': $($_.Exception.Message)"
continue
}
$contacts = $group.member |
Get-ADObject -Properties ObjectClass -ErrorAction SilentlyContinue |
Where-Object { $_.ObjectClass -eq 'contact' }
if (-not $members -and -not $contacts) {
if ($IncludeEmptyGroups) {
$results.Add([PSCustomObject]@{
GroupName = $group.Name; GroupCategory = $group.GroupCategory; GroupScope = $group.GroupScope
MemberName = '(no members)'; MemberSamAccountName = $null; MemberObjectClass = $null
})
}
continue
}
foreach ($member in $members) {
$results.Add([PSCustomObject]@{
GroupName = $group.Name; GroupCategory = $group.GroupCategory; GroupScope = $group.GroupScope
MemberName = $member.Name; MemberSamAccountName = $member.SamAccountName; MemberObjectClass = $member.objectClass
})
}
foreach ($contact in $contacts) {
$results.Add([PSCustomObject]@{
GroupName = $group.Name; GroupCategory = $group.GroupCategory; GroupScope = $group.GroupScope
MemberName = $contact.Name; MemberSamAccountName = $null; MemberObjectClass = 'contact'
})
}
}
if ($results.Count -eq 0) {
Write-Host "No group membership found matching the given criteria." -ForegroundColor Yellow
return
}
$sorted = $results | Sort-Object GroupName, MemberName
Write-Host "Found $(@($sorted).Count) group/member row(s) across $(@($groups).Count) group(s)." -ForegroundColor Cyan
$sorted | Format-Table GroupName, GroupCategory, MemberName, MemberObjectClass -AutoSize
if ($OutputCsv) {
$sorted | Export-Csv -Path $OutputCsv -NoTypeInformation
Write-Host "Exported results to $OutputCsv" -ForegroundColor Green
}
-GroupCategory Security scopes the export to just security groups (drop it to get distribution groups too), and -IncludeEmptyGroups adds a (no members) placeholder row for groups with nobody in them, so empty groups don't just silently disappear from the export.
One row per group/member pair, on purpose
The output format here is deliberately flat — one row per group-member pair, not one row per group with a comma-joined member list — because that's what pivots cleanly in Excel or a spreadsheet tool: filter by group, filter by member, pivot either direction. A nested or joined format looks tidier at a glance but is much harder to actually work with once you're past a few dozen rows.
Where the manual approach runs out of road
A one-off script is fine for a single check. It starts to hurt once you actually need to run this regularly:
- No scheduling. Cron/Task Scheduler can run the script, but now you own the scheduling, the credentials it runs as, and what happens when it silently fails.
- No history. A CSV export is a snapshot. Was this the same five accounts as last month, or a growing list? A single export can't tell you.
- No distribution. Getting the report to the right people (security, IT ops, compliance) on a schedule means building that plumbing yourself.
- Multi-domain/multi-forest pain. Run it once per domain, reconcile the results yourself, and hope naming/OU conventions are consistent across all of them.
- No alerting. If something changes unexpectedly between runs — an account re-enabled, a privileged group gaining a member — nothing tells you until you happen to run the script again.
None of that is a PowerShell problem. It's what turns a script into a product.
What SysFlint AD does instead
Our SysFlint AD runs the same kind of discovery shown above automatically, on a schedule, entirely inside your own network. No agents on domain controllers, no data leaving your environment. It's free, forever.
- Privileged group auditing — Who is in Domain Admins, Enterprise Admins, and Schema Admins — including nested membership and accounts that should not be there.
- Scheduled reports by email — Daily, weekly, or monthly runs delivered to the right inbox automatically. No Task Scheduler job for you to babysit.
- Multi-domain and forest coverage — Enumerate every domain in the forest in one pass and get one consolidated report instead of one per domain.
If you're currently doing this with a script on a scheduled task, here's the honest comparison between the two, or go straight to the download page.
Get this script, and the rest of them
The script above is part of awesome-it-scripts, SysFlint's free, open-source library of PowerShell scripts for Active Directory and other IT-admin tasks — no signup, no catch, MIT licensed. Grab this one directly from active-directory/Get-ADAllGroupsMembershipReport.ps1, or browse the whole thing.
Find every disabled/locked-out/stale/privileged-account script we've published (plus the FAQ that goes with each one) in the repo's active-directory folder. Star it if it's useful — new scripts land there regularly.