Introduction: Managing Active Directory at Scale with PowerShell
When an Active Directory environment grows to thousands of users, hundreds of groups, and dozens of OUs, the graphical tools become a bottleneck. PowerShell and the ActiveDirectory module—installed by default on Windows Server 2019 domain controllers and available on member servers via RSAT—provide the primitives for bulk operations, automated provisioning, and scheduled hygiene tasks. This guide covers the techniques required to manage AD efficiently at scale.
Installing the ActiveDirectory Module
On a domain controller, the module is already present. On a member server or management workstation running Windows Server 2019:
# Install RSAT AD tools (Windows Server 2019)
Install-WindowsFeature -Name RSAT-AD-PowerShell -IncludeManagementTools
# Verify
Get-Module -ListAvailable -Name ActiveDirectory
# Import (auto-imported on demand, but explicit import is good practice)
Import-Module ActiveDirectory
# Target a specific domain controller for all operations in a session
$PDC = (Get-ADDomain).PDCEmulator
Bulk User Creation from CSV
Onboarding large cohorts—students, contractors, or new department hires—can be fully automated. Prepare a CSV with at minimum: GivenName, Surname, Department, Title, and Manager.
Import-Module ActiveDirectory
$ErrorActionPreference = 'Stop'
$users = Import-Csv -Path 'C:ImportsNewHires_2024Q1.csv'
$defaultOU = 'OU=Employees,OU=Corp,DC=corp,DC=local'
$domainSuffix = '@corp.local'
foreach ($u in $users) {
$samAccount = ($u.GivenName.Substring(0,1) + $u.Surname).ToLower() -replace 's',''
$upn = $samAccount + $domainSuffix
$displayName = "$($u.GivenName) $($u.Surname)"
$secPassword = ConvertTo-SecureString 'TempPass!2024' -AsPlainText -Force
# Determine target OU by department
$targetOU = switch ($u.Department) {
'Engineering' { 'OU=Engineering,OU=Employees,OU=Corp,DC=corp,DC=local' }
'Finance' { 'OU=Finance,OU=Employees,OU=Corp,DC=corp,DC=local' }
default { $defaultOU }
}
try {
$existing = Get-ADUser -Filter "SamAccountName -eq '$samAccount'" -ErrorAction SilentlyContinue
if ($existing) {
Write-Warning "User $samAccount already exists, skipping."
continue
}
New-ADUser -SamAccountName $samAccount `
-UserPrincipalName $upn `
-Name $displayName `
-GivenName $u.GivenName `
-Surname $u.Surname `
-DisplayName $displayName `
-Department $u.Department `
-Title $u.Title `
-AccountPassword $secPassword `
-ChangePasswordAtLogon $true `
-Enabled $true `
-Path $targetOU `
-Server $PDC
Write-Output "Created: $samAccount in $targetOU"
}
catch {
Write-Error "Failed to create $samAccount : $_"
}
}
Bulk Group Membership Management
Adding or removing large numbers of users from security or distribution groups efficiently:
# Add all users in Engineering OU to DL-Engineering distribution list
$engUsers = Get-ADUser -Filter * -SearchBase 'OU=Engineering,OU=Employees,OU=Corp,DC=corp,DC=local'
Add-ADGroupMember -Identity 'DL-Engineering' -Members $engUsers
# Remove from a group by CSV list of SAMAccountNames
$toRemove = (Import-Csv 'C:ImportsLeaversApril.csv').SamAccountName
foreach ($sam in $toRemove) {
try {
Remove-ADGroupMember -Identity 'All-Staff' -Members $sam -Confirm:$false
Write-Output "Removed $sam from All-Staff"
} catch { Write-Warning "Could not remove $sam : $_" }
}
# Sync group membership to match a definitive CSV list
$targetMembers = (Import-Csv 'C:ImportsProjectAlpha_Members.csv').SamAccountName
$currentMembers = (Get-ADGroupMember -Identity 'SG-ProjectAlpha').SamAccountName
$toAdd = $targetMembers | Where-Object { $_ -notin $currentMembers }
$toRemove = $currentMembers | Where-Object { $_ -notin $targetMembers }
if ($toAdd) { Add-ADGroupMember -Identity 'SG-ProjectAlpha' -Members $toAdd }
if ($toRemove) { Remove-ADGroupMember -Identity 'SG-ProjectAlpha' -Members $toRemove -Confirm:$false }
Write-Output "Added: $($toAdd.Count) Removed: $($toRemove.Count)"
Stale Account Auditing and Remediation
Accounts that have not logged in for 90 days represent both a security risk and a licensing cost. Automate detection, disablement, and eventual deletion:
$staleDays = 90
$archiveOU = 'OU=Disabled_Accounts,OU=Corp,DC=corp,DC=local'
$deleteDays = 180
$cutoff90 = (Get-Date).AddDays(-$staleDays)
$cutoff180 = (Get-Date).AddDays(-$deleteDays)
# Find and disable stale enabled accounts
$staleAccounts = Search-ADAccount -AccountInactive -TimeSpan ([timespan]::FromDays($staleDays)) `
-UsersOnly -SearchBase 'OU=Employees,OU=Corp,DC=corp,DC=local' |
Where-Object { $_.Enabled -eq $true }
foreach ($acct in $staleAccounts) {
Disable-ADAccount -Identity $acct
Move-ADObject -Identity $acct.DistinguishedName -TargetPath $archiveOU
Set-ADUser -Identity $acct -Description "Auto-disabled $(Get-Date -Format 'yyyy-MM-dd') - inactive $staleDays days"
Write-Output "Disabled and moved: $($acct.SamAccountName)"
}
# Delete accounts that have been disabled for 180+ days
$purgeAccounts = Get-ADUser -Filter { Enabled -eq $false } `
-SearchBase $archiveOU `
-Properties WhenChanged |
Where-Object { $_.WhenChanged -lt $cutoff180 }
foreach ($acct in $purgeAccounts) {
Remove-ADUser -Identity $acct -Confirm:$false
Write-Output "Deleted: $($acct.SamAccountName)"
}
Reporting on OU, Group, and User Attributes at Scale
Large environments require regular audits of group membership depth, empty groups, and missing required attributes:
# Find all security groups with no members
$emptyGroups = Get-ADGroup -Filter { GroupCategory -eq 'Security' } -Properties Members |
Where-Object { $_.Members.Count -eq 0 } |
Select-Object Name, DistinguishedName, GroupScope
$emptyGroups | Export-Csv 'C:ReportsEmptyGroups.csv' -NoTypeInformation
# Audit users missing required attributes
$requiredAttribs = @('Department','Title','Manager','telephoneNumber')
$missingAttrib = Get-ADUser -Filter { Enabled -eq $true } `
-Properties Department, Title, Manager, telephoneNumber |
Where-Object {
-not $_.Department -or -not $_.Title -or
-not $_.Manager -or -not $_.telephoneNumber
} |
Select-Object SamAccountName, Name, Department, Title
$missingAttrib | Export-Csv 'C:ReportsMissingAttributes.csv' -NoTypeInformation
Write-Output "Users with missing attributes: $($missingAttrib.Count)"
# Find nested group membership recursively (effective members of a group)
function Get-ADGroupMemberRecursive {
param([string]$GroupName)
Get-ADGroupMember -Identity $GroupName -Recursive |
Where-Object { $_.objectClass -eq 'user' } |
Get-ADUser -Properties DisplayName, Department
}
Get-ADGroupMemberRecursive -GroupName 'GG-AllAdmins' |
Select-Object SamAccountName, DisplayName, Department |
Export-Csv 'C:ReportsAllAdmins_Effective.csv' -NoTypeInformation
Password Expiry Notifications at Scale
A script that identifies users whose passwords expire within N days and sends them individual email reminders:
Import-Module ActiveDirectory
$warningDays = 14
$smtpServer = 'smtp.corp.local'
$fromAddr = '[email protected]'
$defaultMaxAge = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge.Days
Get-ADUser -Filter { Enabled -eq $true -and PasswordNeverExpires -eq $false } `
-Properties PasswordLastSet, EmailAddress, DisplayName, msDS-UserPasswordExpiryTimeComputed |
ForEach-Object {
$expiryDate = [datetime]::FromFileTime($_.'msDS-UserPasswordExpiryTimeComputed')
$daysLeft = ($expiryDate - (Get-Date)).Days
if ($daysLeft -ge 0 -and $daysLeft -le $warningDays -and $_.EmailAddress) {
$body = @"
Dear $($_.DisplayName),
Your domain password will expire in $daysLeft day(s) on $($expiryDate.ToShortDateString()).
Please change it now by pressing Ctrl+Alt+Del and choosing 'Change a password'.
IT Support
"@
Send-MailMessage -From $fromAddr -To $_.EmailAddress `
-Subject "Action Required: Your password expires in $daysLeft day(s)" `
-Body $body -SmtpServer $smtpServer
Write-Output "Notified: $($_.SamAccountName) - expires $daysLeft days"
}
}
Delegating AD Management with PowerShell
Grant help desk staff the ability to reset passwords and unlock accounts on a specific OU without full Domain Admin rights:
# Get the OU and help desk group
$ouDN = 'OU=Employees,OU=Corp,DC=corp,DC=local'
$hdGroup = Get-ADGroup -Identity 'SG-HelpDesk'
$ouACL = Get-Acl "AD:$ouDN"
# Build an ACE for Reset Password right
$resetPassGuid = [guid]'00299570-246d-11d0-a768-00aa006e0529' # Reset Password extended right
$identity = [System.Security.Principal.SecurityIdentifier]$hdGroup.SID
$adRights = [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight
$aceType = [System.Security.AccessControl.AccessControlType]::Allow
$inheritance = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents
$userClassGuid = [guid]'bf967aba-0de6-11d0-a285-00aa003049e2' # User object GUID
$ace = New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
$identity, $adRights, $aceType, $resetPassGuid, $inheritance, $userClassGuid
)
$ouACL.AddAccessRule($ace)
Set-Acl -Path "AD:$ouDN" -AclObject $ouACL
Write-Output "Reset Password right delegated to SG-HelpDesk on $ouDN"
Conclusion
Managing Active Directory at scale on Windows Server 2019 with PowerShell replaces slow, error-prone manual GUI work with repeatable, auditable automation. From bulk user provisioning through CSV imports, to automated stale-account remediation, password expiry notifications, and delegation of control, every routine AD task benefits from scripting. The techniques shown here form a foundation for a complete AD lifecycle management automation library.