~/2025/06/30/powershell-active-directory-find-and-disable-stale-computer-accounts.md
PowerShell: Active Directory – Find and Disable Stale Computers
--- author: Tom Lasswell date: read: 4 min in: [ps, scripts] tags: [powershell, active-directory, windows] ---
$ grep -n '^#' post.md
Every Active Directory I've ever inherited has the same problem: a computers container full of machines that were retired, re-imaged under a different name, or shipped back to a leasing company years ago, and nobody ever cleaned up the account. Stale computer accounts aren't just clutter. They inflate counts in every tool that keys off AD, they show up as false positives in vulnerability and patch-compliance reports, and each one is still an enabled security principal in the domain. This script finds enabled computers that have stopped authenticating, using two independent signals (lastLogonTimestamp and the machine password age in pwdLastSet), reports on them, and, only when you ask it to, disables them, stamps the original location into the description, and moves them to a quarantine OU instead of deleting them.
Why two signals: a domain-joined Windows machine changes its own account password every 30 days by default (the Domain member: Maximum machine account password age policy). A computer whose pwdLastSet is months old and whose lastLogonTimestamp is equally old has almost certainly not been on the network in that time. Requiring both keeps a machine with one odd attribute from being swept up.
Requirements
- Windows PowerShell 5.1 or PowerShell 7 on Windows, with the ActiveDirectory module (RSAT). On a server:
Install-WindowsFeature RSAT-AD-PowerShell. On Windows 10/11:Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0. - Domain functional level Windows Server 2003 or higher, which is what makes
lastLogonTimestampupdate and replicate. - Read access to computer objects under the search base. For
-Disable: rights to disable accounts and writedescriptionin the source OUs. For-TargetOU: rights to move objects out of the source OUs (delete child) and create computer objects in the target OU. - The quarantine OU must already exist.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
DaysInactive | Int | No | Days since both the last logon timestamp and the last machine password change before an account counts as stale. Defaults to 90. |
SearchBase | String | No | Distinguished name to search under. Defaults to the domain root. |
Disable | Switch | No | Disables each stale account and adds the original OU and date to the front of its description, keeping any existing description text. Without it the script only reports. |
TargetOU | String | No | Distinguished name of an OU to move disabled accounts into. Only used with -Disable. |
ReportPath | String | No | Path to a CSV file the results are written to, in addition to the console. |
The script also honours the common -WhatIf and -Confirm switches, because it declares SupportsShouldProcess.
Usage
Report only, no changes, 90-day threshold, whole domain:
.\Find-StaleComputerAccounts.ps1
Report on one OU with a tighter 60-day window and save the results for review:
.\Find-StaleComputerAccounts.ps1 -DaysInactive 60 -SearchBase "OU=Workstations,DC=corp,DC=example,DC=com" -ReportPath "C:\Reports\stale-computers.csv"
Preview exactly what a disable run would touch, without changing anything:
.\Find-StaleComputerAccounts.ps1 -Disable -TargetOU "OU=Disabled Computers,DC=corp,DC=example,DC=com" -WhatIf
Disable and quarantine:
.\Find-StaleComputerAccounts.ps1 -Disable -TargetOU "OU=Disabled Computers,DC=corp,DC=example,DC=com" -ReportPath "C:\Reports\stale-disabled.csv"
Sample console output from a report-only run:
Cutoff: 2025-04-01 (90 days). Searching DC=corp,DC=example,DC=com ...
4 stale computer account(s) found.
Name LastLogonDate PasswordLastSet DaysInactive OperatingSystem OU
---- ------------- --------------- ------------ --------------- --
KIOSK-LOBBY 2023-11-02 09:14:55 606 Windows 10 Enterprise OU=Kiosks,DC=corp,DC=example,DC=com
FIN-DESK11 2024-12-19 15:03:02 2024-12-05 10:41:17 193 Windows 11 Enterprise OU=Finance,DC=corp,DC=example,DC=com
OLD-LAP03 2025-01-04 08:12:41 2024-12-22 16:20:09 177 Windows 11 Enterprise OU=Workstations,DC=corp,DC=example,DC=com
LAB-SQL02 2025-02-11 22:05:37 2025-01-30 03:11:48 139 Windows Server 2019 Datacenter OU=Lab,DC=corp,DC=example,DC=com
To roll one back later, the description tells you where it came from:
Get-ADComputer -Identity "OLD-LAP03" -Properties Description | Select-Object -Property Name, Description
Enable-ADAccount -Identity "OLD-LAP03"
Move-ADObject -Identity (Get-ADComputer -Identity "OLD-LAP03").DistinguishedName -TargetPath "OU=Workstations,DC=corp,DC=example,DC=com"
Script
<#
.SYNOPSIS
Finds Active Directory computer accounts that have not authenticated recently, and
optionally disables and relocates them.
.DESCRIPTION
Queries enabled computer objects under a search base with an LDAP filter on
lastLogonTimestamp, then keeps only accounts whose lastLogonTimestamp AND pwdLastSet
are both older than the threshold (accounts created inside the window are skipped).
Reports every match. With -Disable, each match is disabled and the original OU and date
are added to the front of its description (existing description text is kept); with
-TargetOU as well, it is moved to a quarantine OU so it is easy to find, restore or,
later, delete.
.PARAMETER DaysInactive
Days since both the last logon timestamp and the last machine password change before
an account counts as stale.
.PARAMETER SearchBase
Distinguished name to search under. Defaults to the domain root.
.PARAMETER Disable
Disables each stale account and adds the original OU and date to the front of its
description, keeping any existing description text.
.PARAMETER TargetOU
Distinguished name of an OU to move disabled accounts into. Only used with -Disable.
.PARAMETER ReportPath
Path to a CSV file the results are written to, in addition to the console.
.EXAMPLE
.\Find-StaleComputerAccounts.ps1 -DaysInactive 60 -Disable -TargetOU "OU=Disabled Computers,DC=corp,DC=example,DC=com" -WhatIf
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2025-06-30)
Requires: ActiveDirectory module (RSAT-AD-PowerShell), Windows Server 2003 domain functional level or higher
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[ValidateRange(1, 3650)]
[int]$DaysInactive = 90,
[string]$SearchBase,
[switch]$Disable,
[string]$TargetOU,
[string]$ReportPath
)
Import-Module ActiveDirectory -ErrorAction Stop
if ($TargetOU -and -not $Disable) {
Write-Warning "-TargetOU was supplied without -Disable; accounts will be reported only, not moved."
}
if (-not $SearchBase) {
$SearchBase = (Get-ADDomain).DistinguishedName
}
$now = Get-Date
$cutoffDate = $now.AddDays(-$DaysInactive)
$cutoffFileTime = $cutoffDate.ToFileTime()
# Enabled computers whose lastLogonTimestamp is older than the cutoff or was never set.
# The userAccountControl bit 2 (ACCOUNTDISABLE) test excludes accounts already disabled.
$ldapFilter = "(&(objectCategory=computer)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(|(lastLogonTimestamp<=$cutoffFileTime)(!(lastLogonTimestamp=*))))"
Write-Host "Cutoff: $($cutoffDate.ToString('yyyy-MM-dd')) ($DaysInactive days). Searching $SearchBase ..."
$candidates = Get-ADComputer -LDAPFilter $ldapFilter -SearchBase $SearchBase -Properties LastLogonDate, PasswordLastSet, WhenCreated, OperatingSystem, Description
$staleComputers = foreach ($computer in $candidates) {
# Skip accounts created inside the window: a new, never-used account is not stale yet.
if ($computer.WhenCreated -gt $cutoffDate) {
continue
}
# Require the machine password to be old too; a live domain member rotates it every 30 days by default.
if ($computer.PasswordLastSet -and $computer.PasswordLastSet -gt $cutoffDate) {
continue
}
$newestSignal = @($computer.LastLogonDate, $computer.PasswordLastSet, $computer.WhenCreated) |
Where-Object { $_ } |
Sort-Object -Descending |
Select-Object -First 1
[PSCustomObject]@{
Name = $computer.Name
LastLogonDate = $computer.LastLogonDate
PasswordLastSet = $computer.PasswordLastSet
DaysInactive = [math]::Floor(($now - $newestSignal).TotalDays)
OperatingSystem = $computer.OperatingSystem
OU = ($computer.DistinguishedName -split '(?<!\\),', 2)[1]
Description = $computer.Description
DistinguishedName = $computer.DistinguishedName
}
}
$staleComputers = @($staleComputers | Sort-Object -Property DaysInactive -Descending)
if ($staleComputers.Count -eq 0) {
Write-Host "No stale computer accounts found."
return
}
Write-Host "$($staleComputers.Count) stale computer account(s) found."
$staleComputers | Format-Table -Property Name, LastLogonDate, PasswordLastSet, DaysInactive, OperatingSystem, OU -AutoSize
if ($ReportPath) {
$staleComputers | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
Write-Host "Report written to $ReportPath"
}
if ($Disable) {
$stamp = $now.ToString('yyyy-MM-dd')
foreach ($computer in $staleComputers) {
if ($PSCmdlet.ShouldProcess($computer.Name, "Disable computer account (inactive $($computer.DaysInactive) days)")) {
try {
# Keep the existing description (owner, asset tag) after the sweep note; AD caps description at 1024 characters.
$description = "Stale sweep $stamp; was in $($computer.OU)"
if ($computer.Description) {
$description = "$description | $($computer.Description)"
}
if ($description.Length -gt 1024) {
Write-Warning "Description of $($computer.Name) truncated to 1024 characters. Original: $($computer.Description)"
$description = $description.Substring(0, 1024)
}
Disable-ADAccount -Identity $computer.DistinguishedName -ErrorAction Stop
Set-ADComputer -Identity $computer.DistinguishedName -Description $description -ErrorAction Stop
if ($TargetOU) {
Move-ADObject -Identity $computer.DistinguishedName -TargetPath $TargetOU -ErrorAction Stop
}
Write-Host "Disabled $($computer.Name)"
} catch {
Write-Warning "Failed on $($computer.Name): $($_.Exception.Message)"
}
}
}
}
Notes
lastLogonTimestampis built for exactly this job and is deliberately imprecise. Microsoft's Directory Services team describes its intended purpose as identifying inactive accounts, and with default settings it lags 9 to 14 days behind reality: a logon only rewrites it when the stored value is older thanmsDS-LogonTimeSyncInterval(default 14 days, "Not Set" in ADSI Edit means 14) minus a random 0 to 5 days. Any threshold under about 30 days will produce false positives. For to-the-day accuracy you would need the non-replicatedlastLogonfrom every DC, or DC security logs.- The
LastLogonDateproperty the module returns islastLogonTimestampconverted to a localDateTime, andPasswordLastSetispwdLastSetconverted the same way, so the script never has to call[DateTime]::FromFileTime()itself. The LDAP filter, by contrast, compares the raw FILETIME integer, which is why the cutoff is converted withToFileTime(). - The filter uses the
LDAP_MATCHING_RULE_BIT_ANDrule (1.2.840.113556.1.4.803) againstuserAccountControlbit 2 to skip disabled accounts on the server side, so the query only returns candidates instead of every computer in the domain. Search-ADAccount -AccountInactive -ComputersOnly -TimeSpan 90.00:00:00is the one-line alternative, and it's fine for a quick look. I prefer the explicit filter because it also checkspwdLastSet, skips freshly created accounts, and leaves disabled accounts out.- The password change is submitted by the domain member itself, so a machine that is powered off or off the network doesn't rotate it. That's what makes an old
pwdLastSeta useful second signal. It's also why disabling beats deleting: a disabled account can simply be re-enabled, while deleting it throws away the object and its SID, and the machine has to be rejoined to the domain. - Run without
-Disablefirst and review the CSV. Some "stale" accounts are seasonal machines, lab equipment, or systems that are powered off for long stretches rather than actually retired. - I keep disabled accounts in the quarantine OU for 30 to 60 days before deleting them. That window has saved me more than once when a "retired" laptop turned out to still be someone's daily driver, and the description stamp makes the move back a single command.
- If the domain has several sites, run it against one DC with
-Serveradded to theGet-ADComputercall if you want repeatable results between runs;lastLogonTimestampreplicates normally (not urgently), so a DC can briefly hold an older value.
Source
- The LastLogonTimeStamp attribute: what it was designed for and how it works (Microsoft AskDS)
- Domain member: Maximum machine account password age
- ms-DS-Logon-Time-Sync-Interval attribute
- Understanding LastLogon, LastLogonTimeStamp and LastLogonDate (TechNet Wiki)
- Search-ADAccount
- Install Remote Server Administration Tools
- Search filter syntax (LDAP matching rules)