~/2025/06/30/powershell-active-directory-find-and-disable-stale-computer-accounts.md

PowerShell: Active Directory – Find and Disable Stale Computers

---
author: 
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 lastLogonTimestamp update and replicate.
  • Read access to computer objects under the search base. For -Disable: rights to disable accounts and write description in 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

NameTypeRequiredDescription
DaysInactiveIntNoDays since both the last logon timestamp and the last machine password change before an account counts as stale. Defaults to 90.
SearchBaseStringNoDistinguished name to search under. Defaults to the domain root.
DisableSwitchNoDisables 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.
TargetOUStringNoDistinguished name of an OU to move disabled accounts into. Only used with -Disable.
ReportPathStringNoPath 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:

powershell
.\Find-StaleComputerAccounts.ps1

Report on one OU with a tighter 60-day window and save the results for review:

powershell
.\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:

powershell
.\Find-StaleComputerAccounts.ps1 -Disable -TargetOU "OU=Disabled Computers,DC=corp,DC=example,DC=com" -WhatIf

Disable and quarantine:

powershell
.\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:

text
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:

powershell
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

powershell
<#
.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

  • lastLogonTimestamp is 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 than msDS-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-replicated lastLogon from every DC, or DC security logs.
  • The LastLogonDate property the module returns is lastLogonTimestamp converted to a local DateTime, and PasswordLastSet is pwdLastSet converted 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 with ToFileTime().
  • The filter uses the LDAP_MATCHING_RULE_BIT_AND rule (1.2.840.113556.1.4.803) against userAccountControl bit 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:00 is the one-line alternative, and it's fine for a quick look. I prefer the explicit filter because it also checks pwdLastSet, 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 pwdLastSet a 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 -Disable first 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 -Server added to the Get-ADComputer call if you want repeatable results between runs; lastLogonTimestamp replicates normally (not urgently), so a DC can briefly hold an older value.

Source