Introduction to Managing Active Directory at Scale with PowerShell
Active Directory environments in enterprise organizations often contain tens of thousands of user accounts, groups, and organizational units. Managing these manually through the GUI is error-prone and impractical. PowerShell’s ActiveDirectory module, installed as part of RSAT (Remote Server Administration Tools), provides a comprehensive set of cmdlets that let administrators manage AD objects in bulk, automate lifecycle processes, and generate detailed compliance reports. This guide covers the most important techniques for managing large-scale Active Directory deployments on Windows Server 2022.
Installing and Importing the ActiveDirectory Module
On Windows Server 2022 domain controllers and member servers with RSAT installed, the ActiveDirectory module is available by default. On Windows 10/11 management workstations, install RSAT first:
# On Windows Server 2022 - install RSAT AD tools
Install-WindowsFeature -Name RSAT-AD-PowerShell
# On Windows 10/11 management workstation
Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0
# Verify the module is available
Get-Module -ListAvailable -Name ActiveDirectory
# Import and verify cmdlet count
Import-Module ActiveDirectory
(Get-Command -Module ActiveDirectory).Count
Most AD cmdlets connect to the nearest domain controller automatically using site awareness. You can specify a particular DC using the -Server parameter on any AD cmdlet, which is useful when targeting a specific site or ensuring write operations go to the PDC emulator:
# Target PDC emulator for write operations
$pdc = (Get-ADDomain).PDCEmulator
Get-ADUser -Filter * -Server $pdc
Bulk User Creation from CSV
One of the most common administrative tasks is onboarding batches of new employees. Prepare a CSV file with the required user attributes and use Import-Csv combined with New-ADUser to create all accounts in a single pass:
# Example CSV structure (save as C:Importnewusers.csv):
# FirstName,LastName,Username,Department,Title,Manager,OU
# John,Smith,jsmith,IT,Systems Engineer,CN=jdoe,OU=IT,DC=corp,DC=example,DC=com,OU=IT,DC=corp,DC=example,DC=com
# Jane,Doe,jdoe,Finance,Analyst,CN=mjones,OU=Finance,DC=corp,DC=example,DC=com,OU=Finance,DC=corp,DC=example,DC=com
Import-Csv -Path "C:Importnewusers.csv" | ForEach-Object {
$securePass = ConvertTo-SecureString "Temp@Pass$(Get-Random -Minimum 1000 -Maximum 9999)" -AsPlainText -Force
$params = @{
GivenName = $_.FirstName
Surname = $_.LastName
Name = "$($_.FirstName) $($_.LastName)"
SamAccountName = $_.Username
UserPrincipalName = "$($_.Username)@corp.example.com"
DisplayName = "$($_.FirstName) $($_.LastName)"
Department = $_.Department
Title = $_.Title
Manager = $_.Manager
Path = $_.OU
AccountPassword = $securePass
Enabled = $true
ChangePasswordAtLogon = $true
PasswordNeverExpires = $false
}
try {
New-ADUser @params
Write-Output "Created: $($_.Username)"
}
catch {
Write-Warning "Failed to create $($_.Username): $($_.Exception.Message)"
}
}
After creation, it is good practice to force a password reset at next logon and optionally send a welcome email with temporary credentials through a separate process. Never log or store plaintext passwords in scripts.
Bulk Group Membership Management
Adding or removing users from groups in bulk is straightforward with Add-ADGroupMember and Remove-ADGroupMember. These cmdlets accept arrays, making bulk operations easy:
# Add a list of users to a group from a text file
$users = Get-Content "C:Importfinance_users.txt"
Add-ADGroupMember -Identity "Finance_All" -Members $users
# Add users from CSV to different groups based on department
Import-Csv "C:Importnewusers.csv" | Group-Object Department | ForEach-Object {
$department = $_.Name
$groupName = "Dept_$department"
$members = $_.Group | Select-Object -ExpandProperty Username
# Ensure group exists before adding
if (Get-ADGroup -Filter { Name -eq $groupName } -ErrorAction SilentlyContinue) {
Add-ADGroupMember -Identity $groupName -Members $members
Write-Output "Added $($members.Count) users to $groupName"
}
else {
Write-Warning "Group $groupName not found"
}
}
# Synchronize group membership — replace entire membership
# First get desired members, then set
$desiredMembers = Import-Csv "C:Importsecurity_group_members.csv" |
Select-Object -ExpandProperty SamAccountName
$existingMembers = Get-ADGroupMember -Identity "Security_Admins" |
Select-Object -ExpandProperty SamAccountName
$toAdd = $desiredMembers | Where-Object { $_ -notin $existingMembers }
$toRemove = $existingMembers | Where-Object { $_ -notin $desiredMembers }
if ($toAdd) { Add-ADGroupMember -Identity "Security_Admins" -Members $toAdd }
if ($toRemove) { Remove-ADGroupMember -Identity "Security_Admins" -Members $toRemove }
Write-Output "Sync complete: +$($toAdd.Count) / -$($toRemove.Count)"
Bulk OU Management
Organizational Unit structures often need to be replicated across domains, or created in bulk during migrations. Use New-ADOrganizationalUnit to build the entire OU hierarchy programmatically:
$domain = "DC=corp,DC=example,DC=com"
$departments = @('IT','Finance','HR','Marketing','Operations','Legal','Sales')
# Create top-level departments OU if missing
$parentOU = "OU=Departments,$domain"
if (-not (Get-ADOrganizationalUnit -Filter { DistinguishedName -eq $parentOU } -ErrorAction SilentlyContinue)) {
New-ADOrganizationalUnit -Name "Departments" -Path $domain -ProtectedFromAccidentalDeletion $true
}
# Create sub-OUs for each department with Users, Groups, Computers sub-containers
foreach ($dept in $departments) {
$deptOU = "OU=$dept,$parentOU"
if (-not (Get-ADOrganizationalUnit -Filter { DistinguishedName -eq $deptOU } -ErrorAction SilentlyContinue)) {
New-ADOrganizationalUnit -Name $dept -Path $parentOU -ProtectedFromAccidentalDeletion $true
foreach ($container in @('Users','Groups','Computers','ServiceAccounts')) {
New-ADOrganizationalUnit -Name $container -Path $deptOU -ProtectedFromAccidentalDeletion $true
}
Write-Output "Created OU structure for $dept"
}
}
Finding and Managing Stale Accounts
Stale user and computer accounts are a security risk. Search-ADAccount provides convenient filters for finding inactive accounts. The -AccountInactive parameter uses the lastLogonTimestamp attribute which AD replicates (unlike lastLogon which is local to each DC):
# Find users inactive for more than 90 days
$cutoff = (Get-Date).AddDays(-90)
$inactiveUsers = Search-ADAccount -AccountInactive -TimeSpan (New-TimeSpan -Days 90) `
-UsersOnly | Where-Object { $_.Enabled -eq $true }
Write-Output "Found $($inactiveUsers.Count) inactive users"
# Export to CSV for review before taking action
$inactiveUsers | Select-Object Name, SamAccountName, LastLogonDate, DistinguishedName |
Export-Csv "C:Reportsinactive_users_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
# Stage 1: Disable and move to a review OU
$reviewOU = "OU=DisabledAccounts,DC=corp,DC=example,DC=com"
foreach ($user in $inactiveUsers) {
Disable-ADAccount -Identity $user.SamAccountName
Move-ADObject -Identity $user.DistinguishedName -TargetPath $reviewOU
# Add note with disable date
Set-ADUser -Identity $user.SamAccountName `
-Description "Disabled $(Get-Date -Format 'yyyy-MM-dd') - inactive 90 days"
}
# Stage 2 (after 30 day hold): Remove accounts disabled more than 30 days ago
$toDelete = Get-ADUser -SearchBase $reviewOU `
-Filter { Description -like "Disabled*" } `
-Properties Description, LastLogonDate |
Where-Object {
$user = $_
try {
$disabledDate = [datetime]::ParseExact(
($user.Description -replace 'Disabled (d{4}-d{2}-d{2}).*','$1'),
'yyyy-MM-dd', $null)
$disabledDate -lt (Get-Date).AddDays(-30)
} catch { $false }
}
$toDelete | Remove-ADUser -Confirm:$false
Write-Output "Removed $($toDelete.Count) accounts"
AD Password Policy Management with Fine-Grained Password Policies
Windows Server 2022 Active Directory supports Fine-Grained Password Policies (PSOs) that override the default domain policy for specific users or groups. Manage them with the AD module:
# View existing fine-grained policies
Get-ADFineGrainedPasswordPolicy -Filter * |
Select-Object Name, MinPasswordLength, MaxPasswordAge, LockoutThreshold, Precedence
# Create a strict policy for privileged accounts
New-ADFineGrainedPasswordPolicy `
-Name "PSO_PrivilegedAccounts" `
-Precedence 10 `
-MinPasswordLength 20 `
-PasswordHistoryCount 24 `
-MaxPasswordAge (New-TimeSpan -Days 60) `
-MinPasswordAge (New-TimeSpan -Days 1) `
-LockoutThreshold 5 `
-LockoutDuration (New-TimeSpan -Minutes 30) `
-LockoutObservationWindow (New-TimeSpan -Minutes 30) `
-ComplexityEnabled $true `
-ReversibleEncryptionEnabled $false
# Apply to the Domain Admins group
Add-ADFineGrainedPasswordPolicySubject -Identity "PSO_PrivilegedAccounts" `
-Subjects "Domain Admins","Enterprise Admins","Schema Admins"
# Check what policy applies to a specific user
Get-ADUserResultantPasswordPolicy -Identity jsmith
Monitoring AD Replication with PowerShell
Replication failures can cause authentication issues and stale data across domain controllers. Monitor replication health proactively:
# Check for replication failures across all DCs
$replicationFailures = Get-ADReplicationFailure -Scope Forest -Target (Get-ADForest).Domains
if ($replicationFailures) {
$replicationFailures | Select-Object Server, Partner, FirstFailureTime, FailureCount, LastError |
Format-Table -AutoSize
Write-Warning "Replication failures detected!"
} else {
Write-Output "No replication failures found"
}
# Get detailed replication status
Get-ADReplicationPartnerMetadata -Target (Get-ADDomain).PDCEmulator -Scope Domain |
Select-Object Server, Partner, LastReplicationSuccess, LastReplicationAttempt, LastReplicationResult |
Format-Table -AutoSize
# Run replication diagnostic (equivalent to repadmin /replsummary)
Get-ADReplicationUpToDatenessVectorTable -Target (Get-ADDomain).PDCEmulator
High-Performance AD Queries with -LDAPFilter
The standard -Filter parameter is convenient but translates to LDAP on the server side with varying efficiency. For large directories with 50,000+ objects, using native -LDAPFilter syntax is significantly faster because the LDAP query is sent directly to the DC without PowerShell translation overhead:
# Standard filter (slower for large directories)
Get-ADUser -Filter { Department -eq 'IT' -and Enabled -eq $true }
# Equivalent LDAP filter (faster, sent directly to DC)
Get-ADUser -LDAPFilter "(&(department=IT)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
# Find all users with no email address set
Get-ADUser -LDAPFilter "(&(objectClass=user)(!(mail=*))(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" `
-Properties mail | Select-Object SamAccountName, Name, DistinguishedName
# Find all users whose password never expires
Get-ADUser -LDAPFilter "(userAccountControl:1.2.840.113556.1.4.803:=65536)" `
-Properties PasswordNeverExpires, LastLogonDate |
Select-Object Name, SamAccountName, LastLogonDate
# Find all computer accounts not logged on in 60 days
$cutoff = (Get-Date).AddDays(-60).ToFileTime()
Get-ADComputer -LDAPFilter "(&(objectClass=computer)(lastLogonTimestamp<=$cutoff))" `
-Properties lastLogonTimestamp |
Select-Object Name, @{N='LastLogon';E={[datetime]::FromFileTime($_.lastLogonTimestamp)}}
The LDAP bitwise AND operator 1.2.840.113556.1.4.803 tests specific bits in the userAccountControl attribute. The bit value 2 means the account is disabled; 65536 means PasswordNeverExpires. Understanding these values gives you precise control over your queries.
Generating AD Reports and Exporting Data
Regular reports are essential for compliance, security audits, and capacity planning. Here is a comprehensive reporting script that generates an HTML report of the AD environment:
# Export all users with key attributes to CSV
Get-ADUser -Filter * -Properties * |
Select-Object `
SamAccountName, DisplayName, EmailAddress, Department, Title,
Manager, Enabled, LastLogonDate, PasswordLastSet, PasswordNeverExpires,
PasswordExpired, LockedOut, Created, Modified, DistinguishedName |
Export-Csv "C:ReportsAD_Users_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
# Export all groups with member counts
Get-ADGroup -Filter * -Properties Members, Description |
Select-Object Name, GroupScope, GroupCategory, Description,
@{N='MemberCount'; E={ ($_.Members | Measure-Object).Count }} |
Export-Csv "C:ReportsAD_Groups_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
# Summary statistics
$stats = [PSCustomObject]@{
TotalUsers = (Get-ADUser -Filter *).Count
EnabledUsers = (Get-ADUser -Filter { Enabled -eq $true }).Count
DisabledUsers = (Get-ADUser -Filter { Enabled -eq $false }).Count
TotalComputers = (Get-ADComputer -Filter *).Count
TotalGroups = (Get-ADGroup -Filter *).Count
TotalOUs = (Get-ADOrganizationalUnit -Filter *).Count
DomainControllers = (Get-ADDomainController -Filter *).Count
}
$stats | Format-List
Bulk Attribute Updates
Correcting or standardizing attributes across many user accounts is a common task after migrations or organizational changes. Use Set-ADUser with -Replace, -Add, or -Clear for different scenarios:
# Update department and title from a CSV mapping file
# CSV format: SamAccountName,NewDepartment,NewTitle,NewManager
Import-Csv "C:Importattribute_updates.csv" | ForEach-Object {
try {
$params = @{
Identity = $_.SamAccountName
Department = $_.NewDepartment
Title = $_.NewTitle
}
if ($_.NewManager) {
$mgr = Get-ADUser -Identity $_.NewManager -ErrorAction Stop
$params['Manager'] = $mgr.DistinguishedName
}
Set-ADUser @params
Write-Output "Updated: $($_.SamAccountName)"
}
catch {
Write-Warning "Failed $($_.SamAccountName): $($_.Exception.Message)"
}
}
# Standardize UPN suffix after domain rename or migration
$oldSuffix = "@oldcorp.com"
$newSuffix = "@corp.example.com"
Get-ADUser -Filter { UserPrincipalName -like "*@oldcorp.com" } | ForEach-Object {
$newUPN = $_.UserPrincipalName -replace [regex]::Escape($oldSuffix), $newSuffix
Set-ADUser -Identity $_.SamAccountName -UserPrincipalName $newUPN
Write-Verbose "Updated UPN for $($_.SamAccountName) to $newUPN"
}
# Bulk clear an attribute (e.g., remove stale proxy addresses)
Get-ADUser -Filter { Department -eq 'Contractors' } | ForEach-Object {
Set-ADUser -Identity $_.SamAccountName -Clear proxyAddresses
}
When performing bulk updates in production, always test your filter on a small subset first by appending | Select-Object -First 5 to preview the affected accounts. Run bulk operations against a test OU or a handful of accounts before expanding to the full population. Keeping a pre-change export as a rollback reference is essential for large-scale AD operations.