Published on

How to Track Group Membership Changes in Active Directory (PowerShell Script)

"Who added this person to Domain Admins, and when" is the kind of question that comes up during an incident review, and it's the kind of question AD itself can't answer directly — group membership is a live snapshot, not a history. The audit trail lives in the Security event log instead, spread across whichever DC happened to process each change.

The quick answer

Get-WinEvent -ComputerName <DC-name> -FilterHashtable @{LogName='Security'; Id=4728,4729,4732,4733}

That covers global and local security groups on one DC. Universal groups and distribution groups use different event IDs again, and you'd still need to repeat this per DC.

A more useful reporting script

#requires -Modules ActiveDirectory
<#
.SYNOPSIS
    Audits Active Directory group membership changes from the Security event log.
#>

[CmdletBinding()]
param(
    [string]$GroupName,
    [int]$Hours = 24,
    [string]$OutputCsv
)

Import-Module ActiveDirectory -ErrorAction Stop

$eventActions = @{
    4728 = 'Added to global security group'; 4729 = 'Removed from global security group'
    4732 = 'Added to local security group'; 4733 = 'Removed from local security group'
    4746 = 'Added to local distribution group'; 4747 = 'Removed from local distribution group'
    4751 = 'Added to global distribution group'; 4752 = 'Removed from global distribution group'
    4756 = 'Added to universal security group'; 4757 = 'Removed from universal security group'
    4761 = 'Added to universal distribution group'; 4762 = 'Removed from universal distribution group'
}

$domainControllers = Get-ADDomainController -Filter *
$startTime = (Get-Date).AddHours(-$Hours)
$results = New-Object System.Collections.Generic.List[object]

foreach ($dc in $domainControllers) {
    Write-Host "Checking $($dc.HostName) for group membership changes since $startTime ..." -ForegroundColor Cyan

    $filterHashtable = @{ LogName = 'Security'; Id = $eventActions.Keys; StartTime = $startTime }

    try {
        $events = Get-WinEvent -ComputerName $dc.HostName -FilterHashtable $filterHashtable -ErrorAction Stop
    } catch {
        if ($_.Exception -is [System.Diagnostics.Eventing.Reader.EventLogNotFoundException] -or
            $_.Exception.Message -like 'No events were found*') {
            Write-Verbose "No group membership change events found on $($dc.HostName) in the given window."
            continue
        }
        Write-Warning "Could not query $($dc.HostName): $($_.Exception.Message)"
        continue
    }

    foreach ($event in $events) {
        $group = $event.Properties[2].Value
        if ($GroupName -and $group -ne $GroupName) { continue }

        $results.Add([PSCustomObject]@{
            TimeCreated  = $event.TimeCreated
            Action       = $eventActions[$event.Id]
            Group        = $group
            Member       = $event.Properties[0].Value
            ChangedBy    = $event.Properties[6].Value
            RecordedOnDC = $dc.HostName
        })
    }
}

if ($results.Count -eq 0) {
    Write-Host "No group membership changes found in the last $Hours hour(s)." -ForegroundColor Yellow
    return
}

$sorted = $results | Sort-Object TimeCreated -Descending

Write-Host "Found $(@($sorted).Count) group membership change(s)." -ForegroundColor Cyan
$sorted | Format-Table TimeCreated, Action, Group, Member, ChangedBy -AutoSize

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

-GroupName "Domain Admins" narrows the audit to just the group you care about — useful for keeping a standing watch on your most sensitive groups without wading through routine distribution-list churn.

The prerequisite nobody remembers until it's too late

None of these events exist unless "Audit Security Group Management" success auditing is enabled via Group Policy (Advanced Audit Policy Configuration > Account Management), and auditing can't be applied retroactively — if it wasn't on when the change happened, there's no record to find, full stop. If this script comes back empty and you're sure a change happened, that's the first thing to check, not a bug in the query.

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