PowerShell Script: Clean Up Stale AD Computer Accounts

Stale computer accounts accumulate silently in Active Directory. They inflate your AD object count, consume endpoint-management licenses, appear in security scans as unmanaged devices, and — in environments that use computer accounts for Kerberos or LAPS — represent a real attack surface. A two-phase cleanup approach is the safe standard: disable and quarantine first, delete only after a grace period confirms no business-critical system was accidentally caught. This script implements both phases with full logging so every action is attributable.
Quick Answer
Use Get-ADComputer -Filter * -Properties LastLogonDate filtered to accounts where LastLogonDate is older than 90 days and the account is enabled. Move them to a quarantine OU and disable them (Phase 1), then delete accounts that have been in quarantine for 30+ days (Phase 2).
Understanding lastLogonDate vs lastLogonTimestamp Replication
Active Directory has two last-logon attributes that behave very differently. lastLogon is updated on every domain controller but is not replicated — it is only accurate on the DC that authenticated the machine. lastLogonTimestamp is replicated, but only refreshed when the value is more than 14 days old, introducing up to a two-week lag. LastLogonDate, which Get-ADComputer surfaces, is simply a DateTime-formatted version of lastLogonTimestamp — so use it, but understand it may be up to 14 days stale. For maximum accuracy, query all domain controllers and take the most recent lastLogon value; for practical fleet cleanup, LastLogonDate is sufficient when you use a conservative threshold like 90 days.
Finding Computers Inactive for 90 Days with Get-ADComputer
Import-Module ActiveDirectory
$cutoffDate = (Get-Date).AddDays(-90)
$quarantineOU = "OU=Quarantine-Computers,DC=corp,DC=local"
$excludedOUs = @(
"OU=Servers,DC=corp,DC=local",
"OU=ServiceWorkstations,DC=corp,DC=local"
)
$staleComputers = Get-ADComputer -Filter {
Enabled -eq $true -and LastLogonDate -lt $cutoffDate
} -Properties LastLogonDate, DistinguishedName, Description |
Where-Object {
$dn = $_.DistinguishedName
-not ($excludedOUs | Where-Object { $dn -like "*$_" })
}
Write-Output "Found $($staleComputers.Count) stale computer accounts older than 90 days."
$staleComputers | Select-Object Name, LastLogonDate, DistinguishedName |
Format-Table -AutoSize
The Where-Object exclusion check filters out any computer whose DN falls under a protected OU. Adjust $excludedOUs to match your environment’s server and critical-device OUs.
Safe Phase 1: Moving to a Quarantine OU and Disabling
Moving to a quarantine OU removes the computers from all group-policy-linked OUs (so Group Policy stops applying) while keeping the account intact for recovery. Disabling the account prevents authentication. Log each action with a timestamped entry so there is an audit trail.
$logPath = "F:\AD-Cleanup\phase1-$(Get-Date -Format 'yyyyMMdd').csv"
$log = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($computer in $staleComputers) {
try {
# Move to quarantine OU
Move-ADObject -Identity $computer.DistinguishedName `
-TargetPath $quarantineOU -ErrorAction Stop
# Disable the account
Disable-ADAccount -Identity $computer.SamAccountName -ErrorAction Stop
# Record the action
Set-ADComputer -Identity $computer.SamAccountName `
-Description "Quarantined $(Get-Date -Format 'yyyy-MM-dd') — stale cleanup" `
-ErrorAction Stop
$log.Add([PSCustomObject]@{
ComputerName = $computer.Name
LastLogonDate = $computer.LastLogonDate
Action = "Disabled+Moved"
Timestamp = Get-Date
Error = $null
})
Write-Output "Phase 1: $($computer.Name) disabled and moved to quarantine."
} catch {
$log.Add([PSCustomObject]@{
ComputerName = $computer.Name
LastLogonDate = $computer.LastLogonDate
Action = "FAILED"
Timestamp = Get-Date
Error = $_.Exception.Message
})
Write-Warning "Failed to process $($computer.Name): $_"
}
}
$log | Export-Csv -Path $logPath -NoTypeInformation
Write-Output "Phase 1 complete. Log: $logPath"
Phase 2: Deleting After a 30-Day Grace Period
Run Phase 2 as a separate scheduled task 30 days after Phase 1. Query the quarantine OU for accounts that were disabled more than 30 days ago by parsing the description field or by checking the Modified attribute against the 30-day threshold.
$graceCutoff = (Get-Date).AddDays(-30)
$toDelete = Get-ADComputer -SearchBase $quarantineOU `
-Filter { Enabled -eq $false } `
-Properties Modified, Description |
Where-Object { $_.Modified -lt $graceCutoff }
foreach ($computer in $toDelete) {
try {
Remove-ADObject -Identity $computer.DistinguishedName `
-Recursive -Confirm:$false -ErrorAction Stop
Write-Output "Phase 2: Deleted $($computer.Name)"
} catch {
Write-Warning "Delete failed for $($computer.Name): $_"
}
}
The -Recursive flag is required if any computer object has child objects (rare, but possible with certain management agents).
Excluding Servers, Service Accounts, and Specific OUs
Always exclude domain controllers, servers in dedicated OUs, and any computer names matching known service-device patterns before running either phase. Add name prefix patterns to a $excludedNamePatterns array and extend the Where-Object filter in the Phase 1 query.
Logging All Actions with Output to CSV
Both phases should write a timestamped CSV log. Retain Phase 1 logs for at least 90 days so you can prove an account existed and was legitimately cleaned up if a helpdesk ticket arrives. Archive Phase 2 deletion logs indefinitely for security audit purposes.
Common Errors
- LastLogonDate is null for computers that never logged on: Newly-created or pre-staged accounts that have never authenticated have a
nullLastLogonDate. The filterLastLogonDate -lt $cutoffDatedoes not match$null, so these accounts are not caught. Handle them explicitly with a second query using-Filter { LastLogonDate -notlike "*" -and WhenCreated -lt $cutoffDate }. - Remove-ADObject fails without -Recursive: If a computer account has child objects — common when certain endpoint management tools write sub-objects —
Remove-ADObjectreturns an error about a non-empty container. Always pass-Recursiveand-Confirm:$falsein Phase 2.
Related Cmdlets / See Also
Wrapping Up
A two-phase approach — quarantine and disable before deleting — gives you the safety net to recover from a mistaken match while still achieving a clean, secure Active Directory. Schedule Phase 1 quarterly, Phase 2 automatically 30 days later, and keep the logs for your security audit trail.


