How to Use PowerShell to Manage Active Directory at Scale on Windows Server 2025
Managing Active Directory in a large organization requires more than clicking through the GUI. When your directory contains thousands of user accounts, hundreds of groups, and dozens of OUs, PowerShell is the only practical tool for bulk operations, reporting, and change tracking. Windows Server 2025 ships with the RSAT Active Directory module and supports the full suite of AD cmdlets, enabling administrators to import hundreds of users from a spreadsheet, locate stale accounts in seconds, and audit group membership programmatically. This guide covers the techniques that matter most at scale.
Prerequisites
- Windows Server 2025 domain controller or a member server with RSAT installed
- Active Directory module:
Import-Module ActiveDirectory - Domain Admin or delegated account with appropriate rights
- PowerShell 5.1 or PowerShell 7.4 with the
ActiveDirectorycompatibility module - A test CSV file for the bulk import exercise
Step 1: Bulk-Import Users from CSV
The most common bulk AD task is onboarding a large group of users — new hires, organizational restructures, or migrated accounts from another domain. Import-Csv combined with New-ADUser handles this efficiently, but password handling must be done securely using ConvertTo-SecureString.
First, prepare a CSV with at minimum these columns:
# Sample: users.csv
# FirstName,LastName,Username,Department,Title,Manager,Password
# Jane,Smith,jsmith,Engineering,Engineer,bjones,P@ssw0rd2025!
# ...
Import-Module ActiveDirectory
$defaultOU = 'OU=NewHires,OU=Users,DC=corp,DC=example,DC=com'
$domain = '@corp.example.com'
$logPath = 'C:LogsADImport_' + (Get-Date -Format 'yyyyMMdd_HHmmss') + '.csv'
$log = [System.Collections.Generic.List[PSCustomObject]]::new()
Import-Csv -Path 'C:Scriptsusers.csv' | ForEach-Object {
$securePass = ConvertTo-SecureString $_.Password -AsPlainText -Force
$params = @{
GivenName = $_.FirstName
Surname = $_.LastName
Name = "$($_.FirstName) $($_.LastName)"
SamAccountName = $_.Username
UserPrincipalName = $_.Username + $domain
DisplayName = "$($_.FirstName) $($_.LastName)"
Department = $_.Department
Title = $_.Title
Path = $defaultOU
AccountPassword = $securePass
Enabled = $true
ChangePasswordAtLogon = $true
}
try {
New-ADUser @params -ErrorAction Stop
$log.Add([PSCustomObject]@{ Username=$_.Username; Status='Created'; Error='' })
}
catch {
$log.Add([PSCustomObject]@{ Username=$_.Username; Status='Failed'; Error=$_.Exception.Message })
Write-Warning "Failed to create $($_.Username): $_"
}
}
$log | Export-Csv -Path $logPath -NoTypeInformation
Write-Host "Import complete. Log saved to $logPath"
Always export an import log. In a batch of 500 users, a handful of duplicates or malformed names will fail silently without one. Never store plaintext passwords in production CSVs — replace the password column with a mechanism to generate random passwords and email them via a secure channel.
Step 2: Searching AD Efficiently with LDAPFilter
The -Filter parameter on Get-ADUser is convenient but compiles to LDAP server-side. For large directories, using raw -LDAPFilter gives you precise control and predictable performance, avoiding the overhead of PowerShell expression translation.
# PowerShell -Filter (simple, slower on very large directories)
Get-ADUser -Filter { Department -eq 'Engineering' -and Enabled -eq $true }
# LDAPFilter (faster, server-side optimized)
Get-ADUser -LDAPFilter '(&(department=Engineering)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))' `
-Properties Department, Title, LastLogonDate
# Find all users whose UPN does not match the primary domain
Get-ADUser -LDAPFilter '(!(userPrincipalName=*@corp.example.com))' `
-Properties UserPrincipalName | Select-Object Name, UserPrincipalName
The LDAP filter (userAccountControl:1.2.840.113556.1.4.803:=2) uses a bitwise AND rule to match disabled accounts (bit 2 of userAccountControl). Negate it with ! to get enabled accounts only.
Step 3: Handling Large Result Sets with Paging
Without paging controls, Get-ADUser returns all matching objects in a single LDAP response, which can exhaust memory and time out on directories with tens of thousands of accounts.
# -ResultPageSize controls LDAP page size (default: 256, max: 1000)
# -ResultSetSize caps total returned objects (omit for all)
$allUsers = Get-ADUser -LDAPFilter '(objectClass=user)' `
-Properties LastLogonDate, PasswordLastSet, Department `
-ResultPageSize 500 `
-ResultSetSize 100000
Write-Host "Total users retrieved: $($allUsers.Count)"
# For truly massive directories, use a server-side SearchBase to partition queries
$ous = Get-ADOrganizationalUnit -Filter * | Select-Object -ExpandProperty DistinguishedName
foreach ($ou in $ous) {
Get-ADUser -SearchBase $ou -SearchScope OneLevel `
-LDAPFilter '(objectClass=user)' `
-Properties Department -ResultPageSize 500
}
Step 4: Inactive Account Reporting
Stale accounts are a security risk. Attackers routinely target accounts that no one monitors. A scheduled report identifying accounts inactive for more than 90 days is a foundational hygiene measure.
$cutoffDate = (Get-Date).AddDays(-90)
# Note: LastLogonDate is replicated (updated every 14 days from DCs)
# For precise data use LastLogon (not replicated) across all DCs
$staleUsers = Get-ADUser -Filter {
LastLogonDate -lt $cutoffDate -and
Enabled -eq $true -and
PasswordLastSet -ne "$null"
} -Properties LastLogonDate, PasswordLastSet, Department, Manager |
Select-Object Name, SamAccountName, LastLogonDate, PasswordLastSet, Department,
@{N='ManagerName'; E={ if ($_.Manager) { (Get-ADUser $_.Manager).Name } else { 'None' } }}
$reportPath = "C:ReportsStaleAccounts_$(Get-Date -Format 'yyyyMMdd').csv"
$staleUsers | Export-Csv -Path $reportPath -NoTypeInformation
Write-Host "$($staleUsers.Count) stale accounts found. Report: $reportPath"
Step 5: Password Expiry Report
$maxPwdAge = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge.Days
$expiryReport = Get-ADUser -Filter { Enabled -eq $true -and PasswordNeverExpires -eq $false } `
-Properties PasswordLastSet, EmailAddress, Department |
Where-Object { $_.PasswordLastSet -ne $null } |
Select-Object Name, SamAccountName, EmailAddress, Department,
@{N='PasswordExpiresOn'; E={ $_.PasswordLastSet.AddDays($maxPwdAge) }},
@{N='DaysUntilExpiry'; E={ ($_.PasswordLastSet.AddDays($maxPwdAge) - (Get-Date)).Days }} |
Sort-Object DaysUntilExpiry
# Show accounts expiring in the next 14 days
$expiryReport | Where-Object { $_.DaysUntilExpiry -le 14 -and $_.DaysUntilExpiry -ge 0 } |
Format-Table -AutoSize
Step 6: Nested Group Membership
Resolving who actually has access via nested group chains is notoriously difficult in the GUI. Get-ADGroupMember -Recursive flattens the entire chain.
# All effective members of a group (including nested)
$groupName = 'Domain Admins'
$members = Get-ADGroupMember -Identity $groupName -Recursive |
Where-Object objectClass -eq 'user' |
Get-ADUser -Properties Department, LastLogonDate
$members | Select-Object Name, SamAccountName, Department, LastLogonDate |
Export-Csv "C:Reports${groupName}_Members.csv" -NoTypeInformation
# Find all groups a user belongs to (direct and nested)
$username = 'jsmith'
$user = Get-ADUser $username -Properties MemberOf
$allGroups = [System.Collections.Generic.HashSet[string]]::new()
function Expand-GroupMembership {
param([string]$GroupDN)
if ($allGroups.Add($GroupDN)) {
$grp = Get-ADGroup $GroupDN -Properties MemberOf
foreach ($parent in $grp.MemberOf) { Expand-GroupMembership $parent }
}
}
foreach ($g in $user.MemberOf) { Expand-GroupMembership $g }
$allGroups | ForEach-Object { (Get-ADGroup $_).Name } | Sort-Object
Step 7: Searching Across Object Types with Get-ADObject
# Search across users, computers, groups, and OUs in one call
Get-ADObject -LDAPFilter '(cn=*SQL*)' -Properties objectClass, description |
Select-Object Name, objectClass, description | Format-Table -AutoSize
# Find recently created objects of any type (last 7 days)
$since = (Get-Date).AddDays(-7).ToFileTime()
Get-ADObject -LDAPFilter "(whenCreated>=$since)" -IncludeDeletedObjects:$false |
Select-Object Name, objectClass, DistinguishedName | Sort-Object objectClass
Step 8: Tracking AD Changes with repadmin
# Show recent changes replicated from a specific DC
# Useful for auditing what changed and when
$dc = 'DC01.corp.example.com'
# Show changes originating from this DC in the last hour
repadmin /showchanges $dc DC=corp,DC=example,DC=com /statistics /filter:"(objectClass=user)"
# Check replication health across all DCs
repadmin /replsummary
# Show replication failures
repadmin /showrepl * /csv | ConvertFrom-Csv | Where-Object { $_.'Number of Failures' -gt 0 }
Conclusion
PowerShell-driven Active Directory management at scale turns what would be days of manual work into scheduled scripts that run unattended. Bulk user imports with proper error logging eliminate onboarding bottlenecks. LDAP filter queries keep large directory searches performant. Inactive account and password expiry reports enforce security hygiene without manual audits. Recursive group membership resolution surfaces hidden access paths. And repadmin integration gives administrators early warning of replication problems before they affect authentication. On Windows Server 2025, combining these techniques with scheduled tasks or Azure Automation gives organizations a robust, auditable AD management foundation.