One in ten AD accounts is dormant. Here is the blast radius.
More than one in ten Active Directory user accounts is dormant, per Microsoft. Each one keeps its rights while nobody watches. The math, the queries to find them, and how to reap them without breaking a service.
More than ten percent of the user accounts in a typical Active Directory are inactive. That is not a security vendor’s scare number, it is Microsoft’s own figure, measured by the last time the password changed and the last logon timestamp. Lepide’s State of Active Directory Security puts it higher, at 21 percent either inactive or abandoned. Take the low end and hold it against a real directory: ten percent of a 2,000 user domain is 200 accounts that still authenticate, still carry whatever rights they were granted, and that no one is watching, because no one uses them.
The obvious read is that this is housekeeping. Stale accounts sound like disk space and tidiness, a chore that lands somewhere below patching and backups. They are the cheapest foothold an intruder can find, because a dormant account holds three properties at once. It keeps its permissions. It produces no activity, so nothing alerts on it. And its password is usually old enough to have leaked in someone else’s breach by now.
CISA and MS-ISAC documented exactly that chain in February 2024. A threat actor logged into a US state government network over the VPN using a former employee’s administrator account that was never disabled after the person left. The credentials had already surfaced in a separate, earlier data breach. From that single login the actor ran LDAP queries against a domain controller and walked out with host and user metadata, which then showed up for sale on a dark web broker. One account, still enabled, still privileged. That was the whole intrusion (advisory AA24-046A).
The blast radius, worked
Do the arithmetic for your own directory. Count enabled user accounts, apply a dormant rate, and you have the number of unmonitored footholds sitting in the domain right now. The privileged column is the one that matters.
| Enabled user accounts | Dormant at 10% (Microsoft floor) | Dormant at 21% (Lepide observed) | Privileged and dormant (3% privileged, same rate) |
|---|---|---|---|
| 500 | 50 | 105 | 3 |
| 2,000 | 200 | 420 | 13 |
| 10,000 | 1,000 | 2,100 | 63 |
The first two columns are just the rate times the count. The last one assumes three percent of your accounts hold elevated rights, a deliberately low stand-in you should replace with your real number, and that dormancy hits them at the same rate as everyone else. That assumption is generous to the defender. Privileged accounts skew toward role leftovers, break-glass accounts, and service identities nobody signs into daily, which means they go dormant more often than ordinary users, not less. And the CISA case is the reminder that the count you care about is not thirteen or sixty-three. It is one. One dormant admin account was the entire breach.
Users are also only half the directory. Every machine that was reimaged, retired, or lost leaves an enabled computer object behind, and those accounts authenticate too. A full accounting doubles the surface you just calculated.
”Dormant” carries a two week margin before you even start
Before trusting any of this, know what the timestamp actually means. The attribute Active Directory uses to find stale accounts, lastLogonTimestamp, does not update on every logon. By Sean Metcalf’s writeup of the replication behavior, the controlling value, msDS-LogonTimeSyncInterval, defaults to 14 days with a randomization skew of roughly five, so the stamp runs about 9 to 14 days behind real activity by design. Microsoft built it that way to keep replication traffic down, not to report when someone last logged in.
lastLogonTimestamp was built to find dead accounts cheaply, not to report when a user last signed in. Read it as a two week smear, not a timestamp.
That cuts both ways. An account flagged dormant on a 90 day threshold has really been silent for somewhere between 76 and 90 days, so the flag is conservative and safe to act on. But an account that looks active inside the window might have gone quiet up to two weeks before the stamp admits it. The fix is to never trust the logon time alone. Cross-check it against PwdLastSet, which is exact and replicates immediately.
Finding them: two queries that should disagree
Microsoft’s own tool is one line. Search-ADAccount -AccountInactive walks lastLogonTimestamp and returns anything past the window, including accounts that have never logged in at all, which are the most suspicious of the lot.
# Enabled users with no logon in 90 days (TimeSpan format is D.HH:MM:SS)
Search-ADAccount -AccountInactive -UsersOnly -TimeSpan 90.00:00:00 |
Where-Object { $_.Enabled } |
Select-Object Name, SamAccountName, LastLogonDate |
Sort-Object LastLogonDate
That list is a starting point, not a delete queue, because it only knows about logon time. Run a second pass with Get-ADUser that adds the password age and the two flags that separate a safe cleanup from an outage: PasswordNeverExpires, which is where service accounts hide, and adminCount, which marks accounts that are or were in a protected group.
$cutoff = (Get-Date).AddDays(-90)
Get-ADUser -Filter { Enabled -eq $true } -Properties LastLogonDate, PasswordLastSet, PasswordNeverExpires, adminCount |
Where-Object { $_.LastLogonDate -lt $cutoff -and $_.PasswordLastSet -lt $cutoff } |
Select-Object Name, SamAccountName, LastLogonDate, PasswordLastSet, PasswordNeverExpires, adminCount |
Sort-Object PasswordLastSet
An account that is dormant by both logon time and password age is a real candidate. One that is dormant but had its password reset last week is not: someone is maintaining it. The rows where adminCount equals 1 are the ones to open first. Those are the thirteen, or the sixty-three.
Computer accounts rot on a different clock
Computers do not follow the user rules, so query them separately. A domain-joined Windows machine rotates its own machine account password every 30 days by default, and those passwords do not expire, and the domain will not disable a machine whose password is stale. As Metcalf puts it, the policy is “more of a guideline than a rule.” That behavior is what makes computer objects easy to read: a live member rotates three times inside 90 days, so a computer whose PasswordLastSet is older than that has almost certainly been offline the whole time.
$cutoff = (Get-Date).AddDays(-90)
Get-ADComputer -Filter { Enabled -eq $true } -Properties LastLogonDate, PasswordLastSet, OperatingSystem |
Where-Object { $_.PasswordLastSet -lt $cutoff } |
Select-Object Name, OperatingSystem, LastLogonDate, PasswordLastSet |
Sort-Object PasswordLastSet
Disable, hold, wait, then delete
Do not delete anything you just found. Microsoft’s guidance is to turn the account off, wait several weeks, and delete only if nothing breaks, and the reason is sitting in the query above: a service account or a user on long leave can look identical to an abandoned one. Disabling is reversible in seconds. Deleting takes the SID and the group memberships with it, and the AD Recycle Bin only partly undoes that.
So stage it. Disable the account, stamp it with the date and reason, and move it to a holding OU so the pending set lives in one place.
$holdingOU = "OU=Disabled-Pending-Deletion,OU=Cleanup,DC=corp,DC=example,DC=com"
Search-ADAccount -AccountInactive -UsersOnly -TimeSpan 90.00:00:00 |
Where-Object { $_.Enabled } |
ForEach-Object {
Disable-ADAccount -Identity $_.SamAccountName
Set-ADUser -Identity $_.SamAccountName -Description ("Disabled for inactivity {0:yyyy-MM-dd}" -f (Get-Date))
Move-ADObject -Identity $_.DistinguishedName -TargetPath $holdingOU
}
Then let the holding OU soak. Thirty days is a reasonable floor for user accounts, but a full monthly and quarterly business cycle is safer, because that is when the payroll run or the quarter-close job driven by a forgotten service account finally fires. The soak window is the actual test: if nobody screams, the account was dead. When the window closes, delete what is still sitting disabled.
# After the soak window. Drop -WhatIf once you have reviewed the list.
Get-ADUser -SearchBase $holdingOU -Filter { Enabled -eq $false } -Properties whenChanged |
Where-Object { $_.whenChanged -lt (Get-Date).AddDays(-30) } |
Remove-ADUser -WhatIf
Keep -WhatIf on until you have read the output. It is the difference between a cleanup and an incident.
What to watch
The number worth tracking is not how many accounts you deleted, it is how many enabled accounts have not authenticated in 90 days, plotted over time. If that count only climbs, the offboarding process is not closing accounts and you are accumulating footholds faster than you retire them. If it settles near zero after each turnover event, deprovisioning is working and the query is just confirming it.
The state agency in the CISA advisory was not negligent across the board. It ran a VPN, it had a domain, it responded once the stolen data surfaced. The one thing it skipped was disabling an account it no longer needed, and that single omission was the entire way in. Every dormant account you can count is a door you are not watching. The math only tells you how many.
Sources
- Regularly check for and remove inactive user accounts in Active Directory | Microsoft Learn
- Search-ADAccount (ActiveDirectory) | Microsoft Learn
- The LastLogonTimeStamp Attribute and Replication | ADSecurity (Sean Metcalf)
- Machine Account (AD Computer Object) Password Updates | ADSecurity (Sean Metcalf)
- Why Inactive Users Are a Major Risk to Your Active Directory Security | Lepide
- Threat Actor Leverages Compromised Account of Former Employee (CISA AA24-046A) | The Hacker News
- Threat Actor Leverages Compromised Account of Former Employee to Access State Government Organization | CISA
Share
Related field notes
-
Turn on DNS scavenging without deleting records you still need
Windows DNS zones fill up with A and PTR records for servers you retired years ago. Aging and scavenging cleans them out, but a wrong interval deletes live records. Here is the careful way to enable it.
-
Find the service accounts nobody has rotated
The Commvault Metallic breach ended with CISA telling customers to rotate static application secrets. Your Active Directory is full of the same thing: service accounts whose passwords were set once, years ago, and never touched. Here is the PowerShell to find them and put them on a cadence.
-
Your backup server is joined to the domain it exists to recover
Joining your backup server to the production Active Directory domain puts your last line of recovery inside the same trust boundary as the systems it protects, so one Domain Admin compromise reaches the backups too. Here is the evidence and what to change.
-
Nobody broke your MFA. They took the token it handed out.
Across four theft vectors and a half-dozen named campaigns, the pattern is the same: the session cookie, refresh token, or Entra PRT minted after MFA is the live credential, and the controls that guard the login don't guard the session.
Get the free CVE triage cheat sheet
Subscribe and we'll email you the one-page triage flow for fresh CVEs. Plus the weekly digest.
Subscribe