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.