Emergency server help: get in touch

AD Change Audit Reporter: Find Who Changed Active Directory (PowerShell)

Read-only PowerShell report of Active Directory changes: who changed which user, group or computer, old and new values, and the source workstation when the logs allow it.

Version
1.0.0
Last updated
October 7, 2026
Language
PowerShell
Tested on
Run on 6 Oct 2026 on a Windows Server 2025 DC (build 26100.33438, Windows PowerShell 5.1) in our lab domain with a Windows 11 Pro member; syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25.
License
MIT
Pricing
Free

Tested on Windows Server 2025. Version 1.0.0 was run on 6 October 2026 against real events 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 output below is from that run.

Instead of filtering Event Viewer for twenty event IDs and matching logon sessions by hand, run one command and get a readable list of who changed what in Active Directory: users, groups, computers and any object with an auditing SACL. Attribute changes show the old and the new value, and the source workstation and IP are added when the DC logged a matching logon.

This script does not enable auditing. Your domain controllers must already record the events: follow How to Audit Active Directory Changes first.

Requirements

  • A domain controller with the auditing from the guide. We tested on Windows Server 2025; the events it reads have the same fields on 2016, 2019 and 2022, but we have not run it there yet.
  • Windows PowerShell 5.1 or PowerShell 7. No extra modules.
  • Membership of Administrators or Event Log Readers on the DC whose log you read.
  • For -ComputerName: the Remote Event Log Management firewall rules enabled on that DC.

Install and run

# on a DC, or a management server with rights to read the DC Security log
Invoke-WebRequest https://srvscripts.com/get/ad-change-audit-reporter/ -OutFile Get-ADChangeAudit.ps1
Get-Content .\Get-ADChangeAudit.ps1 | Select-Object -First 40     # read before you run
.\Get-ADChangeAudit.ps1 -Days 7

Use the Download button on this page, then copy the file to the DC (or a management PC with the Remote Event Log Management firewall rules open). A file downloaded with a browser is marked as coming from the internet, so unblock it once: Unblock-File .\Get-ADChangeAudit.ps1. If your execution policy is AllSigned, sign it with your code-signing certificate or run it from a session started with powershell -ExecutionPolicy Bypass -File .\Get-ADChangeAudit.ps1.

Examples

.\Get-ADChangeAudit.ps1 -Days 7                                   # everything in the last week
.\Get-ADChangeAudit.ps1 -User "CONTOSO\ADuser" -Days 30            # what one admin changed
.\Get-ADChangeAudit.ps1 -Target "Jane Doe" -Days 30 -Detailed      # everything done to one object
.\Get-ADChangeAudit.ps1 -EventId 4728,4732,4756 -Days 90           # who added members to groups
.\Get-ADChangeAudit.ps1 -ComputerName DC02 -Days 1                  # another domain controller
.\Get-ADChangeAudit.ps1 -Days 30 -ExportCsv C:\Reports\AD-Audit.csv  # CSV for incident notes
.\Get-ADChangeAudit.ps1 -Days 7 -PassThru | Where-Object Action -like "Password*"

Output

The default view is one line per change: time, actor, action, target, the attribute change (old → new) and the source computer. -Detailed lists every field; -PassThru returns objects and -ExportCsv writes these columns:

ColumnMeaning
Timestamp, EventIDWhen and which event
ActorDOMAIN\account that made the change
Action, Target, ObjectTypeWhat happened to which object
Attribute, OldValue, NewValue5136 attribute changes (old and new value paired by OpCorrelationID)
GroupGroup for membership changes
ObjectDNDistinguished name of the object or member
LogonIDThe actor’s logon session on the DC
Workstation, SourceIP, LogonTypeFrom the matching 4624 logon, or “Not available”
DomainControllerDC that logged the event

From the lab DC, after changing a test user’s phone number twice and creating, moving and deleting a test group (-Days 1 -Target bob, trimmed):

Time         Actor                 Action                         Target      Change                                      Source
----         -----                 ------                         ------      ------                                      ------
06-Oct 14:17 CONTOSO\Administrator User created                   CONTOSO\bob                                             WINSRV
06-Oct 14:17 CONTOSO\Administrator Password reset                 CONTOSO\bob                                             WINSRV
06-Oct 14:17 CONTOSO\Administrator User enabled                   CONTOSO\bob                                             WINSRV
06-Oct 14:17 CONTOSO\Administrator Added to global group Helpdesk Bob Smith                                               WINSRV
06-Oct 14:34 CONTOSO\Administrator Modified user                  Bob Smith   telephoneNumber:  -> +1 555 0100            WINSRV
06-Oct 14:34 CONTOSO\Administrator Modified user                  Bob Smith   telephoneNumber: +1 555 0100 -> +1 555 0199 WINSRV

The script pairs the two halves of each 5136 change (value deleted, value added) into one “old -> new” line. Events written while the domain controller was being promoted show the actor as NT AUTHORITY\ANONYMOUS LOGON; that is normal for dcpromo and not an attack.

Event IDs it reads

EventsMeaningAudit subcategory
5136, 5137, 5139, 5141Directory object modified, created, moved, deleted (needs SACLs)Directory Service Changes
4720, 4722, 4723, 4724, 4725, 4726, 4738, 4767User created, enabled, password changed, password reset, disabled, deleted, changed, unlockedUser Account Management
4728/4729, 4732/4733, 4756/4757Member added/removed: global, domain local, universal security groupSecurity Group Management
4741, 4742, 4743Computer created, changed, deletedComputer Account Management
4624Logon, read only to find the source of a changeLogon

Troubleshooting

  • “No matching changes”: auditing or SACLs are not configured, or you are reading a DC that did not process the change. Check with the read-only commands in the guide.
  • Warning that the oldest event is newer than your window: the Security log has already overwritten older events. Increase its size or forward events.
  • Access denied: run elevated, as a member of Administrators or Event Log Readers on the DC.
  • Workstation “Not available”: no matching 4624 on that DC (Kerberos network logons may lack a workstation name, or the logon is older than the log).
  • Remote DC fails: enable the Remote Event Log Management firewall rules on the DC.

Limitations

  • Reads one DC per run. Run it against each DC with -ComputerName; it does not read a Windows Event Collector’s ForwardedEvents log yet.
  • Only events still in the Security log can be reported.
  • Attribute values are as logged: long strings are truncated and binary values show as <binary>.
  • Source workstation and IP are reported only when a matching logon is found; the script never guesses.

Security

The script is read-only: it calls Get-WinEvent and nothing else. It does not change Active Directory, audit policy, SACLs, GPOs, passwords or event logs, and it sends nothing anywhere. The CSV it writes can contain account names and IP addresses: store it like any other audit evidence.

Changelog

  • 0.1.0-draft: first version for lab validation on Windows Server 2025.

The script

Get-ADChangeAudit.ps1Download
# AD Change Audit Reporter: Find Who Changed Active Directory (PowerShell) (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/ad-change-audit-reporter/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
    Who changed what in Active Directory, when, and (when the logs allow it) from which computer.

.DESCRIPTION
    Read-only. Reads the Security log of a domain controller and turns directory-change and account-management
    events into one readable activity report:
      5136 5137 5139 5141                 directory object modified / created / moved / deleted (needs SACLs)
      4720 4722 4723 4724 4725 4726 4738 4767   user account created / enabled / password change / reset /
                                                disabled / deleted / changed / unlocked
      4728 4729 4732 4733 4756 4757       member added / removed: global, domain local, universal security group
      4741 4742 4743                      computer account created / changed / deleted
    5136 attribute changes are reported as one row: the "Value Deleted" event gives the old value and the
    "Value Added" event the new value (they share an OpCorrelationID).
    The source computer is taken from the 4624 logon event on the same DC whose TargetLogonId matches the
    actor's SubjectLogonId. If no such logon is found, Workstation and SourceIP say "Not available": the script
    never guesses.

    The script does NOT enable auditing, change SACLs, GPOs, accounts or logs. Your DCs must already record
    these events: see https://srvscripts.com/guides/audit-active-directory-changes/

.PARAMETER Days            Look back this many days (default 7). Ignored when -Start is given.
.PARAMETER Start / End     Explicit time window.
.PARAMETER User            Only changes made by this actor (sAMAccountName or DOMAIN\name, wildcards allowed).
.PARAMETER Target          Only changes to this target (name, DN fragment or wildcard), e.g. "Jane Doe" or "*Sales*".
.PARAMETER EventId         Only these event IDs.
.PARAMETER ComputerName    Domain controller to read (default: this computer). Run once per DC: events are per DC.
.PARAMETER LogonLookbackHours  How far before the window to search for the matching 4624 logon (default 12).
.PARAMETER Detailed        Show every field as a list instead of the summary table.
.PARAMETER PassThru        Output objects (for Where-Object, Export-Csv, ConvertTo-Json ...).
.PARAMETER ExportCsv       Also write the result to this CSV file.

.EXAMPLE  .\Get-ADChangeAudit.ps1 -Days 7
.EXAMPLE  .\Get-ADChangeAudit.ps1 -User "CONTOSO\ADuser" -Days 30
.EXAMPLE  .\Get-ADChangeAudit.ps1 -Target "Jane Doe" -Days 30 -Detailed
.EXAMPLE  .\Get-ADChangeAudit.ps1 -EventId 4728,4732,4756 -Days 90      # who added members to groups
.EXAMPLE  .\Get-ADChangeAudit.ps1 -Days 30 -ExportCsv C:\Reports\AD-Audit.csv

.NOTES
    srvScripts — https://srvscripts.com/scripts/ad-change-audit-reporter/   License: MIT
    Version 1.0.0 — run on 6 Oct 2026 against real events on a Windows Server 2025 domain controller (srvScripts lab).
    Requires: Windows PowerShell 5.1 or PowerShell 7, rights to read the DC Security log (Administrators or
    Event Log Readers on the DC), and for -ComputerName the Remote Event Log Management firewall rules.
#>
[CmdletBinding()]
param(
    [int]$Days = 7,
    [datetime]$Start,
    [datetime]$End = (Get-Date),
    [string]$User,
    [string]$Target,
    [int[]]$EventId,
    [string]$ComputerName = $env:COMPUTERNAME,
    [int]$LogonLookbackHours = 12,
    [switch]$Detailed,
    [switch]$PassThru,
    [string]$ExportCsv
)

Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
$ScriptVersion = '1.0.0'

$ChangeIds = 5136,5137,5139,5141,4720,4722,4723,4724,4725,4726,4738,4767,4728,4729,4732,4733,4756,4757,4741,4742,4743
$Actions = @{
    5136='Modified'; 5137='Created'; 5139='Moved'; 5141='Deleted'
    4720='User created'; 4722='User enabled'; 4723='Password changed (by user)'; 4724='Password reset'
    4725='User disabled'; 4726='User deleted'; 4738='User changed'; 4767='User unlocked'
    4728='Added to global group'; 4729='Removed from global group'
    4732='Added to local group'; 4733='Removed from local group'
    4756='Added to universal group'; 4757='Removed from universal group'
    4741='Computer created'; 4742='Computer changed'; 4743='Computer deleted'
}
$NA = 'Not available'

if (-not $PSBoundParameters.ContainsKey('Start')) { $Start = $End.AddDays(-[math]::Abs($Days)) }
if ($Start -ge $End) { throw "-Start must be earlier than -End." }
$ids = if ($EventId) { @($EventId | Where-Object { $ChangeIds -contains $_ }) } else { $ChangeIds }
if (-not $ids) { throw "None of the -EventId values is a change event this script reads: $($ChangeIds -join ', ')" }

function Get-EventData([System.Diagnostics.Eventing.Reader.EventRecord]$e) {
    $h = @{}
    foreach ($d in ([xml]$e.ToXml()).Event.EventData.Data) { $h[$d.Name] = [string]$d.'#text' }
    $h
}
function Get-Cn([string]$dn) {
    if (-not $dn -or $dn -eq '-') { return '' }
    if ($dn -match '^(?:CN|OU)=((?:\\,|[^,])+)') { return ($Matches[1] -replace '\\,', ',') }
    $dn
}
function Join-Account([string]$domain, [string]$name) {
    if (-not $name -or $name -eq '-') { return '' }
    if ($domain -and $domain -ne '-') { "$domain\$name" } else { $name }
}

# ---- Read access and retention check ------------------------------------------------------------------
$gw = @{ ComputerName = $ComputerName }
try {
    $oldest = Get-WinEvent @gw -LogName Security -MaxEvents 1 -Oldest
} catch [System.UnauthorizedAccessException] {
    throw "Access denied reading the Security log on $ComputerName. Run as a member of Administrators or Event Log Readers on that DC."
} catch {
    if ($_.Exception.Message -match 'denied|unauthori') { throw "Access denied reading the Security log on $ComputerName. Run as a member of Administrators or Event Log Readers on that DC." }
    throw "Cannot read the Security log on ${ComputerName}: $($_.Exception.Message)"
}
if ($oldest -and $oldest.TimeCreated -gt $Start) {
    Write-Warning ("The oldest event in the Security log on {0} is from {1:yyyy-MM-dd HH:mm}. Anything before that has been overwritten and cannot be reported. Increase the Security log size or forward events to a collector." -f $ComputerName, $oldest.TimeCreated)
}

# ---- Collect change events -----------------------------------------------------------------------------
try {
    $events = @(Get-WinEvent @gw -FilterHashtable @{ LogName = 'Security'; Id = $ids; StartTime = $Start; EndTime = $End })
} catch {
    if ($_.FullyQualifiedErrorId -match 'NoMatchingEventsFound' -or $_.Exception.Message -match 'No events were found') { $events = @() }
    else { throw }
}

$rows = [System.Collections.Generic.List[object]]::new()
$pairs = @{}   # 5136: OpCorrelationID|ObjectDN|Attribute -> row

foreach ($e in ($events | Sort-Object TimeCreated)) {
    $d = Get-EventData $e
    $id = $e.Id
    $row = [ordered]@{
        Timestamp = $e.TimeCreated; EventID = $id; Actor = (Join-Account $d['SubjectDomainName'] $d['SubjectUserName'])
        Action = $Actions[$id]; Target = ''; ObjectType = ''; Attribute = ''; OldValue = ''; NewValue = ''; Group = ''
        ObjectDN = ''; LogonID = [string]$d['SubjectLogonId']; Workstation = $NA; SourceIP = $NA; LogonType = ''
        DomainController = $e.MachineName
    }
    $handled = $false
    switch -Regex ([string]$id) {
        '^5136$' {
            $key = '{0}|{1}|{2}' -f $d['OpCorrelationID'], $d['ObjectDN'], $d['AttributeLDAPDisplayName']
            $op = $d['OperationType']   # %%14674 Value Added, %%14675 Value Deleted
            if ($pairs.ContainsKey($key)) {
                $r = $pairs[$key]
                if ($op -eq '%%14675') { $r.OldValue = $d['AttributeValue'] } else { $r.NewValue = $d['AttributeValue'] }
                $handled = $true; break
            }
            $row.Target = Get-Cn $d['ObjectDN']; $row.ObjectDN = $d['ObjectDN']; $row.ObjectType = $d['ObjectClass']
            $row.Attribute = $d['AttributeLDAPDisplayName']
            if ($op -eq '%%14675') { $row.OldValue = $d['AttributeValue'] } else { $row.NewValue = $d['AttributeValue'] }
            $row.Action = 'Modified ' + $d['ObjectClass']
            $obj = New-Object psobject -Property $row
            $pairs[$key] = $obj; $rows.Add($obj); $handled = $true; break
        }
        '^(5137|5141)$' { $row.Target = Get-Cn $d['ObjectDN']; $row.ObjectDN = $d['ObjectDN']; $row.ObjectType = $d['ObjectClass']; $row.Action = "$($Actions[$id]) $($d['ObjectClass'])" }
        '^5139$' { $row.Target = Get-Cn $d['NewObjectDN']; $row.ObjectDN = $d['NewObjectDN']; $row.ObjectType = $d['ObjectClass']; $row.OldValue = $d['OldObjectDN']; $row.NewValue = $d['NewObjectDN']; $row.Action = "Moved $($d['ObjectClass'])" }
        '^(4728|4729|4732|4733|4756|4757)$' {
            $row.Target = if ($d['MemberName'] -and $d['MemberName'] -ne '-') { Get-Cn $d['MemberName'] } else { $d['MemberSid'] }
            $row.ObjectDN = $d['MemberName']; $row.Group = Join-Account $d['TargetDomainName'] $d['TargetUserName']; $row.ObjectType = 'group membership'
            $row.Action = "$($Actions[$id]) $($d['TargetUserName'])"
        }
        '^(4741|4742|4743)$' { $row.Target = Join-Account $d['TargetDomainName'] $d['TargetUserName']; $row.ObjectType = 'computer' }
        default { $row.Target = Join-Account $d['TargetDomainName'] $d['TargetUserName']; $row.ObjectType = 'user' }
    }
    if (-not $handled) { $rows.Add((New-Object psobject -Property $row)) }
}

# ---- Filters -------------------------------------------------------------------------------------------
$out = $rows.ToArray()
if ($User) {
    $u = if ($User -match '\\') { $User } else { "*\$User" }
    $out = @($out | Where-Object { $_.Actor -like $u -or $_.Actor -like $User })
}
if ($Target) {
    $t = if ($Target -match '[*?]') { $Target } else { "*$Target*" }
    $out = @($out | Where-Object { $_.Target -like $t -or $_.ObjectDN -like $t -or $_.Group -like $t })
}

# ---- Source computer: 4624 on the same DC with TargetLogonId = actor's SubjectLogonId ------------------
$cache = @{}
foreach ($r in $out) {
    $lid = $r.LogonID
    if (-not $lid -or $lid -eq '0x3e7' -or $lid -eq '-') { if ($lid -eq '0x3e7') { $r.Workstation = 'Local SYSTEM'; $r.SourceIP = 'Local' }; continue }
    if (-not $cache.ContainsKey($lid)) {
        $hit = $null
        $xp = "*[System[(EventID=4624)]] and *[EventData[Data[@Name='TargetLogonId']='$lid']]"
        try {
            $hit = Get-WinEvent @gw -LogName Security -FilterXPath $xp -MaxEvents 5 |
                Where-Object { $_.TimeCreated -le $r.Timestamp -and $_.TimeCreated -ge $Start.AddHours(-$LogonLookbackHours) } |
                Select-Object -First 1
        } catch { $hit = $null }
        $cache[$lid] = if ($hit) { Get-EventData $hit } else { $null }
    }
    $l = $cache[$lid]
    if ($l) {
        if ($l['WorkstationName'] -and $l['WorkstationName'] -ne '-') { $r.Workstation = $l['WorkstationName'] }
        if ($l['IpAddress'] -and $l['IpAddress'] -notin '-', '::1', '127.0.0.1') { $r.SourceIP = $l['IpAddress'] }
        elseif ($l['IpAddress'] -in '::1', '127.0.0.1') { $r.SourceIP = 'Local (on the DC)' }
        $r.LogonType = $l['LogonType']
    }
}

# ---- Output --------------------------------------------------------------------------------------------
if ($ExportCsv) {
    $out | Select-Object Timestamp,EventID,Actor,Action,Target,ObjectType,Attribute,OldValue,NewValue,Group,ObjectDN,LogonID,Workstation,SourceIP,LogonType,DomainController |
        Export-Csv -Path $ExportCsv -NoTypeInformation -Encoding UTF8
    Write-Host ("{0} change(s) written to {1}" -f $out.Count, $ExportCsv)
}
if ($PassThru) { return $out }
if (-not $out.Count) {
    Write-Host ("No matching changes on {0} between {1:yyyy-MM-dd HH:mm} and {2:yyyy-MM-dd HH:mm}. If you expected some, check that auditing and SACLs are configured (see the guide)." -f $ComputerName, $Start, $End)
    return
}
if ($Detailed) {
    $out | Format-List Timestamp,EventID,Actor,Action,Target,ObjectType,ObjectDN,Attribute,OldValue,NewValue,Group,LogonID,Workstation,SourceIP,LogonType,DomainController
} else {
    $out | Select-Object @{n='Time';e={$_.Timestamp.ToString('dd-MMM HH:mm')}}, Actor, Action, Target,
        @{n='Change';e={ if ($_.Attribute) { "{0}: {1} -> {2}" -f $_.Attribute, $_.OldValue, $_.NewValue } else { '' } }},
        @{n='Source';e={ if ($_.Workstation -ne $NA) { $_.Workstation } elseif ($_.SourceIP -ne $NA) { $_.SourceIP } else { $NA } }} |
        Format-Table -AutoSize -Wrap
}
Version 1.0.0 · SHA-256 80a99028b57cf837b25b1af7db3b9386bafd4f355d1d84276513a3e1c8eedf6d
Download and verify on Linux or macOS
curl -fsSL -o Get-ADChangeAudit.ps1 https://scr.srvscripts.com/ad-change-audit-reporter/Get-ADChangeAudit.ps1 && curl -fsSL https://scr.srvscripts.com/ad-change-audit-reporter/Get-ADChangeAudit.ps1.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/ad-change-audit-reporter/Get-ADChangeAudit.ps1' -OutFile 'Get-ADChangeAudit.ps1'; if ((Get-FileHash 'Get-ADChangeAudit.ps1' -Algorithm SHA256).Hash -eq '80A99028B57CF837B25B1AF7DB3B9386BAFD4F355D1D84276513A3E1C8EEDF6D') { '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

Changelog

  • 1.0.0 (6 Oct 2026): validated against real 5136/5137/5139, 4720-4767, 4728-4757 and 4741-4743 events on a Windows Server 2025 DC.
  • 0.1.0-draft: first draft.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.