PowerShell Active Directory Audit Report Script

Quarterly security reviews require the same AD data every time: who is in privileged groups, which accounts have passwords that never expire, which users have not logged in for 90 days, and which passwords are about to expire. With a PowerShell active directory audit script, this entire data gathering takes two minutes instead of two hours, and the results are consistent and reproducible. This post builds each section of the audit report and combines them into an Excel-ready CSV export.
List Domain Admins and Enterprise Admins
Privileged group membership is the most critical section of any AD audit. Check both direct and nested membership:
Import-Module ActiveDirectory
function Get-PrivilegedGroupMembers {
param([string[]]$Groups)
foreach ($group in $Groups) {
try {
Get-ADGroupMember -Identity $group -Recursive -ErrorAction Stop |
Where-Object objectClass -eq 'user' |
ForEach-Object {
$user = Get-ADUser -Identity $_.SamAccountName -Properties EmailAddress, LastLogonDate, Enabled
[PSCustomObject]@{
Group = $group
Username = $user.SamAccountName
DisplayName = $user.Name
Email = $user.EmailAddress
Enabled = $user.Enabled
LastLogonDate = $user.LastLogonDate
}
}
}
catch { Write-Warning "Could not query group $group : $_" }
}
}
$privilegedGroups = @('Domain Admins','Enterprise Admins','Schema Admins','Administrators')
$privilegedMembers = Get-PrivilegedGroupMembers -Groups $privilegedGroups
Write-Host "Privileged accounts found: $($privilegedMembers.Count)"
$privilegedMembers | Format-Table Group, Username, Enabled -AutoSize
Find Users with Password Never Expires
Service accounts often have PasswordNeverExpires set legitimately, but regular user accounts with this flag are a security risk that auditors frequently flag:
$neverExpires = Get-ADUser -Filter { PasswordNeverExpires -eq $true -and Enabled -eq $true } `
-Properties PasswordNeverExpires, LastLogonDate, PasswordLastSet, Department |
Select-Object Name, SamAccountName, Department, LastLogonDate, PasswordLastSet,
@{N='Section'; E={'Password Never Expires'}}
Write-Host "Active users with Password Never Expires: $($neverExpires.Count)"
Find Stale and Disabled Users
Stale enabled accounts (no logon in 90 days) and disabled accounts that have not been removed are both security risks:
$cutoff = (Get-Date).AddDays(-90)
$staleEnabled = Get-ADUser -Filter { Enabled -eq $true } -Properties LastLogonDate |
Where-Object { $_.LastLogonDate -lt $cutoff -or $_.LastLogonDate -eq $null } |
Select-Object Name, SamAccountName, LastLogonDate, DistinguishedName,
@{N='Section'; E={'Stale — No Logon 90+ Days'}}
$disabledAccounts = Get-ADUser -Filter { Enabled -eq $false } -Properties LastLogonDate, WhenChanged |
Select-Object Name, SamAccountName, LastLogonDate,
@{N='DisabledDate'; E={ $_.WhenChanged }},
@{N='Section'; E={'Disabled Accounts'}}
Write-Host "Stale enabled accounts: $($staleEnabled.Count)"
Write-Host "Disabled accounts: $($disabledAccounts.Count)"
Password Expiry in Next 14 Days
Warn users and admins about passwords expiring soon. This requires the domain’s Maximum Password Age policy:
$maxAge = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge.Days
$expiryDate = (Get-Date).AddDays(14)
$expiringPasswords = Get-ADUser -Filter { Enabled -eq $true -and PasswordNeverExpires -eq $false } `
-Properties PasswordLastSet, EmailAddress |
Where-Object {
$_.PasswordLastSet -and
$_.PasswordLastSet.AddDays($maxAge) -le $expiryDate
} |
Select-Object Name, SamAccountName, EmailAddress,
@{N='PasswordExpires'; E={ $_.PasswordLastSet.AddDays($maxAge).ToString('yyyy-MM-dd') }},
@{N='Section'; E={'Password Expiring in 14 Days'}}
Write-Host "Passwords expiring in 14 days: $($expiringPasswords.Count)"
Empty Security Groups
Empty security groups accumulate over time after projects and teams dissolve. They are noise in permission audits and waste administrative attention:
$emptyGroups = Get-ADGroup -Filter { GroupCategory -eq 'Security' } |
Where-Object { (Get-ADGroupMember -Identity $_.DistinguishedName -ErrorAction SilentlyContinue).Count -eq 0 } |
Select-Object Name, DistinguishedName,
@{N='Section'; E={'Empty Security Groups'}}
Write-Host "Empty security groups: $($emptyGroups.Count)"
Export Findings to Excel-Ready CSV
Combine all findings into a single CSV with a Section column so auditors can filter by finding type in Excel:
$reportPath = "C:\Reports\AD-AuditReport_$(Get-Date -Format 'yyyyMMdd').csv"
$allFindings = @(
$privilegedMembers | Select-Object Section, @{N='Name';E={$_.DisplayName}}, @{N='Username';E={$_.Username}}, @{N='Detail';E={$_.Group}}
$neverExpires | Select-Object Section, Name, SamAccountName, @{N='Detail';E={$_.PasswordLastSet}}
$staleEnabled | Select-Object Section, Name, SamAccountName, @{N='Detail';E={$_.LastLogonDate}}
$expiringPasswords | Select-Object Section, Name, SamAccountName, @{N='Detail';E={$_.PasswordExpires}}
$emptyGroups | Select-Object Section, Name, @{N='SamAccountName';E={$_.Name}}, @{N='Detail';E={$_.DistinguishedName}}
)
$allFindings | Export-Csv -Path $reportPath -NoTypeInformation
Write-Host "Audit report: $reportPath ($($allFindings.Count) findings)"
Common Errors and Fixes
-
PasswordNeverExpires flag may be overridden by Fine-Grained Policy. The domain-level
PasswordNeverExpiresattribute on a user object does not account for Fine-Grained Password Policies (PSOs) applied to the user or their groups. UseGet-ADUserResultantPasswordPolicy -Identity $userto get the effective policy for each user in a thorough audit. -
Privileged group membership not inherited — check nested groups. A user who is a member of a group that is a member of Domain Admins is effectively a domain admin. Always use
-RecursivewithGet-ADGroupMemberfor privileged group audits to catch nested membership.
Related Cmdlets / See Also
Wrapping Up
An automated AD audit script consistently finds the same categories of risk: over-privileged accounts, passwords that never expire, stale accounts, and empty groups. Run it monthly rather than quarterly to catch issues before they reach an auditor’s report, and combine the CSV output with your ticketing system to track remediation progress.


