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 ActiveDirectory compatibility 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.