PowerShell · Scripts

Finding and Disabling Stale Computer Accounts in Active Directory

Every decommissioned laptop and re-imaged server leaves its computer account behind in Active Directory, and a script that finds those accounts is only trustworthy if it reports before it disables and never deletes on the first pass.

Quick idea: Search-ADAccount -AccountInactive -ComputersOnly finds the candidates in one line, but a script safe enough to run unattended needs to exclude domain controllers and cluster name objects, export a CSV before touching anything, and default to Disable-ADAccount — never Remove-ADComputer — behind -WhatIf.
Search-ADAccount

Wraps the replicated lastLogonTimestamp attribute to find accounts inactive for a given period.

LastLogonTimestamp

Replicated between domain controllers, but with a built-in lag of roughly 9 to 14 days.

Disable-ADAccount

Reversible in one command with Enable-ADAccount — the reason this script never deletes.

What This Script Is For

Machines leave an environment constantly: a laptop gets re-imaged with a new name, a server is decommissioned, a VM template is spun up once for testing and never touched again. The computer account each of those machines created in Active Directory does not leave with them. It sits in its OU, still enabled, still a valid Kerberos principal, until someone notices or an audit flags it.

Think of a stale computer account as an unlocked door to an office that closed months ago — nobody is using it, but it still answers a knock. Finding those accounts is a straightforward filter on inactivity. Deciding what to do with them safely is where most home-grown scripts go wrong, usually by deleting on the first pass and finding out later that one of them was a cluster name object.

Why Disable, Not Delete

Remove-ADComputer is one cmdlet away and looks tempting once you have a clean list of candidates. The problem is that deletion is not reversible in any practical sense — a deleted computer object goes to the AD Recycle Bin if it is enabled, but restoring it correctly means also rebuilding SID history, group memberships, and any Group Policy or delegation that referenced it, none of which the Recycle Bin restores automatically. Disabling costs nothing to undo: Enable-ADAccount flips the same flag back.

Delete First

Fast to run, expensive to reverse — a wrongly-flagged account means an unplanned rebuild, not a one-line undo.

Disable First

The account stops authenticating immediately but every attribute, group membership, and GPO link stays intact.

Delete Later

Once a disabled account has stayed that way for an agreed grace period, deletion becomes a documented, low-risk second pass.

This script only disables. Treat deletion as a separate, later exercise against the accounts that stayed disabled through at least one full review cycle — that is a process decision for your environment, not something a script should decide unattended.

Why Active Directory Cares About Stale Computer Accounts

A computer account is a full security principal with its own password, its own Kerberos service tickets, and — if nobody has cleaned it up — a machine account password that AD still considers valid until it stops rotating on its own. An account nobody is monitoring is exactly the kind of object that turns up in an attack path: it can be a target for resource-based constrained delegation abuse, it can be re-used if an attacker manages to rejoin a device under the same name, and every stale object left in place is one more thing dragging out a domain migration or a permissions audit.

There is a cost even without an attacker involved. Stale objects inflate the domain’s object count, confuse anyone reading Get-ADComputer -Filter * output for capacity or licensing purposes, and leave dangling references in Group Policy security filtering long after the machine they applied to is gone.

Related cleanup: A decommissioned machine usually leaves DNS records behind as well as a computer account — see Stale DNS Records After a Domain Controller Decommission for the matching cleanup on the DNS side.

The Detection Signals: LastLogon vs LastLogonTimestamp vs PasswordLastSet

Three attributes look interchangeable and are not. LastLogon is accurate to the second but only updated on the one domain controller that handled the logon — it is never replicated, so checking it on a single DC can make an active machine look stale simply because it authenticated against a different DC. LastLogonTimestamp (exposed by the AD module as the friendlier LastLogonDate) is replicated to every DC, but Active Directory only updates it when the existing value is older than the current time minus msDS-LogonTimeSyncInterval, which defaults to 14 days minus a random 0–5 day jitter — so in practice it lags real activity by roughly 9 to 14 days. PasswordLastSet tracks the computer account’s own password rotation, which happens automatically roughly every 30 days while the machine is joined, powered on, and reachable.

Practical rule: Treat an -InactiveDays threshold as a floor, not an exact cutoff. A machine flagged as inactive for 90 days genuinely stopped authenticating somewhere between roughly 76 and 90 days ago, because LastLogonTimestamp itself can already be up to 14 days behind the real last logon.
Attribute Meaning Replication Behaviour
LastLogonExact last authentication time on one specific DC.Not replicated — must be queried against every DC to be trusted.
LastLogonTimestamp / LastLogonDateA replicated approximation of last authentication.Replicated, but only updated after 9–14 days of no update to avoid replication storms.
PasswordLastSetWhen the computer account last rotated its own password.Replicated normally; stalls along with the machine once it stops being reachable.
EnabledWhether the account can currently authenticate at all.Replicated normally; this is the flag Disable-ADAccount flips.
PrimaryGroupIDRID of the account’s primary group — 516 for Domain Controllers, 521 for RODCs.Replicated normally; used here to exclude DCs from candidacy outright.

The Script

The script runs in three logical passes over the same candidate list: find, review, and — only when explicitly asked — disable. Search-ADAccount -AccountInactive -ComputersOnly does the initial filtering using the replicated lastLogonTimestamp attribute described above. Every candidate is then re-queried with Get-ADComputer for the extra properties needed to decide whether it is genuinely safe to touch: domain controllers (PrimaryGroupID 516 or 521) and anything carrying a cluster-related ServicePrincipalName are skipped outright, and any account whose password changed more recently than the inactivity threshold is flagged for manual review instead of being treated as a clean candidate. Nothing gets disabled unless -Disable is passed, and even then Disable-ADAccount runs behind $PSCmdlet.ShouldProcess, so -WhatIf previews the exact set of accounts before any of them are touched.

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 3650)]
    [int]$InactiveDays = 90,

    [Parameter(Mandatory = $false)]
    [string]$SearchBase,

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]$ReportPath = "C:\Temp\Reports\StaleComputerAccounts",

    [Parameter(Mandatory = $false)]
    [switch]$Disable
)

Set-StrictMode -Version Latest
Import-Module ActiveDirectory -ErrorAction Stop

if (-not (Test-Path -LiteralPath $ReportPath)) {
    New-Item -ItemType Directory -Path $ReportPath -Force | Out-Null
}

$cutoffDate = (Get-Date).AddDays(-$InactiveDays)

# Step 1: find candidates using the replicated lastLogonTimestamp attribute
$searchParams = @{
    AccountInactive = $true
    TimeSpan        = (New-TimeSpan -Days $InactiveDays)
    ComputersOnly   = $true
}
if ($SearchBase) { $searchParams['SearchBase'] = $SearchBase }

$candidates = Search-ADAccount @searchParams
$results = [System.Collections.Generic.List[pscustomobject]]::new()

foreach ($candidate in $candidates) {

    # Step 2: pull the extra properties Search-ADAccount does not return,
    # so every row can be reviewed before anything is disabled
    $computer = Get-ADComputer -Identity $candidate.DistinguishedName `
        -Properties LastLogonDate, PasswordLastSet, OperatingSystem, Enabled, PrimaryGroupID, ServicePrincipalName

    $isDomainController = $computer.PrimaryGroupID -in 516, 521

    $isClusterObject = $false
    foreach ($spn in $computer.ServicePrincipalName) {
        if ($spn -match 'MSClusterVirtualServer|MSServerClusterMgmtAPI') {
            $isClusterObject = $true
            break
        }
    }

    $result = [pscustomobject]@{
        Name              = $computer.Name
        DistinguishedName = $computer.DistinguishedName
        LastLogonDate     = $computer.LastLogonDate
        PasswordLastSet   = $computer.PasswordLastSet
        OperatingSystem   = $computer.OperatingSystem
        Status            = 'Unknown'
        Detail            = ''
    }

    if (-not $computer.Enabled) {
        $result.Status = 'Skipped'
        $result.Detail = 'Account is already disabled'
    }
    elseif ($isDomainController) {
        $result.Status = 'Skipped'
        $result.Detail = 'PrimaryGroupID indicates a domain controller (516/521) - never touched automatically'
    }
    elseif ($isClusterObject) {
        $result.Status = 'Skipped'
        $result.Detail = 'ServicePrincipalName suggests a cluster name object - verify manually before touching'
    }
    elseif ($computer.PasswordLastSet -and $computer.PasswordLastSet -gt $cutoffDate) {
        $result.Status = 'Review'
        $result.Detail = 'PasswordLastSet is more recent than the inactivity threshold - confirm before disabling'
    }
    else {
        # Step 3: disable only when explicitly requested; ShouldProcess honours -WhatIf and -Confirm
        if ($Disable) {
            if ($PSCmdlet.ShouldProcess($computer.DistinguishedName, 'Disable stale AD computer account')) {
                try {
                    Disable-ADAccount -Identity $computer.DistinguishedName -ErrorAction Stop
                    $result.Status = 'Disabled'
                    $result.Detail = "Disabled after $InactiveDays+ days of inactivity"
                }
                catch {
                    $result.Status = 'Failed'
                    $result.Detail = $_.Exception.Message
                }
            }
            else {
                $result.Status = 'Skipped'
                $result.Detail = 'WhatIf - no account disabled'
            }
        }
        else {
            $result.Status = 'Candidate'
            $result.Detail = 'Re-run with -Disable to act on this account'
        }
    }

    $results.Add($result)
}

# Always export a CSV of what was found and what happened to it, before anything else
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$csvPath = Join-Path -Path $ReportPath -ChildPath "StaleComputerAccounts_$timestamp.csv"
$results | Export-Csv -LiteralPath $csvPath -NoTypeInformation

$results | Format-Table -AutoSize
$summary = $results | Group-Object Status | Select-Object Name, Count
Write-Output "`nSummary:"
$summary | Format-Table -AutoSize
Write-Output "Report written to: $csvPath"
Production note: The default run — no -Disable switch — never calls Disable-ADAccount at all. It only reports. That is deliberate: the first time this script runs against a new environment, the CSV is the deliverable, not a changed directory.

Run it like this:

# Report only - lists candidates and writes the CSV, changes nothing (the default)
.\Disable-StaleADComputer.ps1 -InactiveDays 90

# Preview exactly which accounts would be disabled, without changing anything
.\Disable-StaleADComputer.ps1 -InactiveDays 90 -Disable -WhatIf

# Disable for real once the WhatIf output and CSV both look correct
.\Disable-StaleADComputer.ps1 -InactiveDays 90 -Disable

Reading the Results

Status Meaning
CandidateInactive long enough and cleared every safety check, but the script was run without -Disable. Nothing was touched.
DisabledDisable-ADAccount ran successfully. Reverse it at any time with Enable-ADAccount.
ReviewFlagged, not acted on – PasswordLastSet is newer than the inactivity threshold, which usually means recent activity that LastLogonTimestamp has not caught up to yet.
SkippedExcluded by a safety check: already disabled, a domain controller, a likely cluster object, or excluded by -WhatIf.
FailedDisable-ADAccount was called and returned an error. Detail holds the exception message.

Troubleshooting Cheat Sheet

Symptom Likely Cause Fix
Search-ADAccount returns far fewer computers than expected -InactiveDays is shorter than the current msDS-LogonTimeSyncInterval replication lag, or the domain functional level predates Windows Server 2003. Widen -InactiveDays to at least 14 more than your target, and confirm the domain functional level supports lastLogonTimestamp.
Every row status is Failed with an access-related message The account running the script lacks permission to modify userAccountControl on the target computer objects. Delegate the right to disable accounts on the relevant OUs, or run the script under an account that already has it.
A machine you know is active still shows up as a candidate It authenticates to a domain controller other than the one you last checked LastLogon on, or it has been offline just long enough to miss the LastLogonTimestamp replication window. Cross-check LastLogon (not LastLogonTimestamp) against every DC before disabling anything borderline, using Get-ADDomainController -Filter * to enumerate them.
A disabled account needs to come back The machine was reimaged, redeployed, or the disable was premature. Run Enable-ADAccount -Identity <name> – nothing else about the object changed.
Cluster nodes or the cluster name object appear as candidates Long-uptime cluster members can go a long time between the kind of interactive logons that refresh lastLogonTimestamp the same way a workstation does. Confirm the ServicePrincipalName exclusion caught it; if not, add the object’s distinguished name to a manual exclusion list before running with -Disable.

Final Thoughts

The hard part of this script was never finding inactive computer accounts — Search-ADAccount does that in one line. The hard part is making sure the accounts it finds are actually safe to touch, and making the touching itself reversible by default.

Run the report-only mode first, every time, against a directory you have not audited before. Read the CSV. Only add -Disable once the candidate list matches what you expected to see.

Key takeaway: If every row reads Disabled, Skipped, or an expected Review, the pass was clean. Anything under Failed carries the exact AD exception needed to fix that one account without re-running the whole batch.
Next in this series

The companion piece worth reading next is regex in PowerShell — -match, -replace, and Select-String — since tightening the cluster and service-account exclusion patterns in a script like this one is exactly the kind of problem regex is built for.