Active Directory clean-up: inactive accounts, empty groups and stale permissions
A directory nobody tidies collects accounts of people who have left, computers that no longer exist and permissions granted for a moment several years ago. The queries that reveal them, and a removal procedure that can be undone.
A review before an audit shows that the directory has noticeably more enabled user accounts than the company has employees. Among them are accounts of people who left several years ago, test accounts with passwords that never expire, and computers that disappeared from the network but still have an account and a valid machine password. Each of them is an entry point nobody watches and an item that has to be explained to the auditor.
Cleaning up the directory needs no third-party tools. A few PowerShell queries run once a month are enough, together with a procedure in which every change can be undone for at least 30 days.
How the directory measures activity
Active Directory stores the last logon date in two attributes. lastLogon is accurate but not replicated: each domain controller knows only the logons it handled itself. lastLogonTimestamp is replicated, but updated only when the stored value is older than 14 days minus a random amount of up to 5 days. As a result its value can lag by 9 to 14 days. The LastLogonDate property in PowerShell is exactly this lastLogonTimestamp, converted.
Computers have an additional indicator: by default they change their machine account password every 30 days. A computer whose PasswordLastSet is older than 90 days has not talked to a domain controller for a long time.
Problem map
| Problem | Query | What to do |
|---|---|---|
| Inactive user accounts | Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -UsersOnly | confirm with the line manager or HR, disable, move to quarantine |
| Inactive computers | Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -ComputersOnly | disable, move, delete after 30 days |
| Password never expires | Search-ADAccount -PasswordNeverExpires -UsersOnly | personal accounts: clear the flag; service accounts: gMSA, or an owner and a password change plan |
| Service accounts with an SPN | Get-ADUser -LDAPFilter '(servicePrincipalName=*)' | a password of at least 25 characters, owner in the description, gMSA where possible |
| Empty groups | Get-ADGroup -LDAPFilter '(!(member=*))' | check use in permissions and GPOs, move, delete |
adminCount leftovers | Get-ADUser -LDAPFilter '(adminCount=1)' | clear the attribute, restore permission inheritance |
| Unconstrained delegation | Get-ADComputer -Filter 'TrustedForDelegation -eq $true' | replace with constrained delegation or remove; acceptable only on domain controllers |
Full versions of the queries, with filters and report columns, are in the “Check your own environment” section.
Inactive user and computer accounts
A user account inactive for 90 days is not always abandoned. Emergency accounts, service accounts run once a quarter and accounts of people on long leave all log on rarely. That is why disabling a user account is preceded by confirmation from the line manager or HR, and exceptions are marked so that the script skips them.
Disabling an account synchronised to Entra ID also blocks sign-in in the cloud after the next synchronisation cycle. The mailbox and licence remain until the account is deleted, so the decision about forwarding mail is made before that step.
Computers are simpler: if both LastLogonDate and PasswordLastSet are older than 90 days, the machine is not working on the network. It is often hardware retired without removing its account, or a machine reinstalled under a new name.
Non-expiring passwords and service accounts
The PasswordNeverExpires flag on a personal account usually means a temporary fix from years ago. On service accounts it is more common and has a justification, but it needs an owner and a password change plan.
User accounts with the servicePrincipalName attribute deserve particular attention. Any authenticated domain user can request a Kerberos ticket for such a service and try to crack its password offline, leaving no trace of logon attempts. A long random password makes this impractical, and a gMSA account whose password the directory changes automatically solves the problem entirely. The krbtgt account also has an SPN, but it is a system account and is not part of this clean-up.
Empty groups, adminCount and delegation
Empty groups. A group without members grants nobody access, but it clutters the directory and can be reused by accident. Before deleting it, check whether it is used in share permissions, in GPO security filtering, or as a deny group that is empty on purpose. Domain Users and Domain Computers look empty, because membership through the primary group is not stored in the member attribute; the query in the “Check your own environment” section skips them through isCriticalSystemObject.
adminCount. An account removed from a privileged group keeps adminCount=1 and disabled permission inheritance. The clean-up consists of clearing the attribute with Set-ADUser -Identity <account> -Clear adminCount and enabling inheritance under Security › Advanced. More on privileged groups in the tiering model.
Unconstrained delegation. A computer with the TrustedForDelegation flag receives the TGTs of all users who authenticate to it. Compromising such a server gives the attacker the tickets of every administrator who connected to it. Domain controllers have the flag by design; on other machines it is replaced with delegation constrained to specific services.
The procedure: disable, move, delete after 30 days
Every change starts with a report saved to a file and a run with -WhatIf, which shows what the command would do without changing anything. The example below covers computers; for users the steps are the same, with Get-ADUser and Set-ADUser.
$cutoff = (Get-Date).AddDays(-90)
$stale = Get-ADComputer -Filter 'Enabled -eq $true' -Properties LastLogonDate, PasswordLastSet, whenCreated |
Where-Object { (-not $_.LastLogonDate -or $_.LastLogonDate -lt $cutoff) -and $_.PasswordLastSet -lt $cutoff -and $_.whenCreated -lt $cutoff }
$stale | Select-Object Name, LastLogonDate, PasswordLastSet, DistinguishedName |
Export-Csv -Path .\inactive-computers.csv -NoTypeInformation -Encoding UTF8
# First with -WhatIf; after checking the result, run again without it
$quarantine = 'OU=To delete,DC=corp,DC=example,DC=com'
$stamp = Get-Date -Format 'yyyy-MM-dd'
foreach ($c in $stale) {
Disable-ADAccount -Identity $c.DistinguishedName -WhatIf
Set-ADComputer -Identity $c.DistinguishedName -Description "Disabled $stamp, no logon for 90 days" -WhatIf
Move-ADObject -Identity $c.DistinguishedName -TargetPath $quarantine -WhatIf
}
For 30 days the change is undone with Enable-ADAccount and by moving the object back. After that, objects in quarantine are deleted. Nobody modifies objects in the quarantine unit, so whenChanged matches the date of the move.
$quarantine = 'OU=To delete,DC=corp,DC=example,DC=com'
$limit = (Get-Date).AddDays(-30)
Get-ADComputer -SearchBase $quarantine -Filter 'Enabled -eq $false' -Properties whenChanged |
Where-Object { $_.whenChanged -lt $limit } |
Remove-ADObject -Recursive -WhatIf
The last safety net is the Active Directory Recycle Bin. It is enabled with Enable-ADOptionalFeature -Identity 'Recycle Bin Feature' -Scope ForestOrConfigurationSet -Target 'corp.example.com', at a forest functional level of at least Windows Server 2008 R2. This operation cannot be undone. A deleted object can then be restored with all its attributes and group memberships, by default for 180 days.
Get-ADObject -Filter 'isDeleted -eq $true -and Name -like "PC-0142*"' -IncludeDeletedObjects |
Restore-ADObject
Check your own environment
All the commands below only read the directory.
Inactive user and computer accounts. Every entry is either a documented exception or a candidate for the procedure. If an account has an empty LastLogonDate, it has never logged on; check its creation date before treating it as abandoned.
Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -UsersOnly |
Where-Object Enabled |
Sort-Object LastLogonDate |
Select-Object SamAccountName, LastLogonDate, DistinguishedName
Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -ComputersOnly |
Where-Object Enabled |
Sort-Object LastLogonDate |
Select-Object Name, LastLogonDate, DistinguishedName
Non-expiring passwords and service accounts with an SPN. Personal accounts on the first list need fixing. On the second, the priority is accounts with a PasswordLastSet several years old and no owner in the description.
Search-ADAccount -PasswordNeverExpires -UsersOnly |
Where-Object Enabled |
Select-Object SamAccountName, LastLogonDate
Get-ADUser -LDAPFilter '(servicePrincipalName=*)' -Properties servicePrincipalName, PasswordLastSet, Description |
Where-Object SamAccountName -ne 'krbtgt' |
Select-Object SamAccountName, PasswordLastSet, Description, @{ n = 'SPN'; e = { $_.servicePrincipalName -join '; ' } }
Empty groups and accounts with adminCount=1. Empty groups need a usage check before deletion. Any account with adminCount=1 that is not currently a member of a privileged group is a leftover to clean up.
Get-ADGroup -LDAPFilter '(&(!(member=*))(!(isCriticalSystemObject=TRUE)))' -Properties whenChanged, Description |
Select-Object Name, GroupScope, whenChanged, Description
Get-ADUser -LDAPFilter '(adminCount=1)' -Properties LastLogonDate |
Select-Object SamAccountName, Enabled, LastLogonDate
Unconstrained delegation outside domain controllers. A good result is two empty lists.
# 524288 = TRUSTED_FOR_DELEGATION, 516 = primary group of domain controllers
Get-ADComputer -LDAPFilter '(&(userAccountControl:1.2.840.113556.1.4.803:=524288)(!(primaryGroupID=516)))' |
Select-Object Name, DistinguishedName
Get-ADUser -LDAPFilter '(userAccountControl:1.2.840.113556.1.4.803:=524288)' |
Select-Object SamAccountName