Published on

How to List Security Group Members in Active Directory (PowerShell Script)

"Who's actually in this group right now" is one of the most-asked questions in any access review, and it's usually asked about a group that controls something sensitive — a file share, an admin role, a VPN policy. The answer needs to be complete, which is where the built-in cmdlet has a gap most people don't find out about until it bites them.

The quick answer

Get-ADGroupMember -Identity "IT-Admins"

A more useful reporting script

#requires -Modules ActiveDirectory
<#
.SYNOPSIS
    Lists every member of a specific Active Directory security group.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$GroupName,
    [switch]$Recursive,
    [string]$OutputCsv
)

Import-Module ActiveDirectory -ErrorAction Stop

$group = Get-ADGroup -Identity $GroupName -Properties GroupCategory, GroupScope, member -ErrorAction Stop

if ($group.GroupCategory -ne 'Security') {
    Write-Warning "$GroupName is a $($group.GroupCategory) group, not a security group — membership is still reported."
}

$memberParams = @{ Identity = $GroupName }
if ($Recursive) { $memberParams['Recursive'] = $true }

$members = Get-ADGroupMember @memberParams

$results = @(foreach ($member in $members) {
    [PSCustomObject]@{
        GroupName         = $GroupName
        MemberName        = $member.Name
        SamAccountName    = $member.SamAccountName
        ObjectClass       = $member.objectClass
        DistinguishedName = $member.DistinguishedName
    }
})

# Get-ADGroupMember doesn't return contacts — pull them from the raw member
# attribute instead so they aren't silently missing from the report.
$contacts = $group.member |
    Get-ADObject -Properties ObjectClass -ErrorAction SilentlyContinue |
    Where-Object { $_.ObjectClass -eq 'contact' }

foreach ($contact in $contacts) {
    $results += [PSCustomObject]@{
        GroupName         = $GroupName
        MemberName        = $contact.Name
        SamAccountName    = $null
        ObjectClass       = 'contact'
        DistinguishedName = $contact.DistinguishedName
    }
}

if (-not $results) {
    Write-Host "$GroupName has no members." -ForegroundColor Yellow
    return
}

$sorted = $results | Sort-Object ObjectClass, MemberName

Write-Host "$GroupName has $(@($sorted).Count) member(s)." -ForegroundColor Cyan
$sorted | Format-Table MemberName, SamAccountName, ObjectClass, DistinguishedName -AutoSize

if ($OutputCsv) {
    $sorted | Export-Csv -Path $OutputCsv -NoTypeInformation
    Write-Host "Exported results to $OutputCsv" -ForegroundColor Green
}

The gap in Get-ADGroupMember that catches people out

Get-ADGroupMember only returns objects that can hold a SID — users, computers, and nested groups. Contact objects don't have one, so they're silently excluded from the results even though they're genuinely members of the group. If a group is used to control access to something and a contact object was added to it (deliberately or by mistake), Get-ADGroupMember alone will never tell you. The raw member attribute on the group object doesn't have this limitation, which is why the script cross-checks it.

Checking a user's membership instead of a group's

Sometimes you have the user and want their groups, not the other way around:

Get-ADPrincipalGroupMembership -Identity jdoe | Select-Object Name

That only shows direct membership, though — for the full picture including nested groups, see how to find nested group membership in an earlier post.

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-ADSecurityGroupMembers.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.