What ten years of Active Directory actually contains
Every Active Directory older than the people administering it contains roughly the same set of things. Accounts for people who left in 2019. Computer objects for laptops that were scrapped. Groups nested inside groups nested inside groups, where nobody can say what the outer one grants. A service account in Domain Admins because that was the fastest way to make an install work one afternoon.
There is no shortage of scripts that will find these. What is missing from almost every guide is the other half: which findings are dangerous to act on. An AD cleanup that deletes the wrong object does not produce an error — it produces a cluster that will not come online at the next failover, three months later, with no obvious cause.
This is what the audit finds, why each item matters, and what not to touch.
"Stale" is not what lastLogonTimestamp tells you
Almost every cleanup script sorts by lastLogonTimestamp. That attribute is deliberately inaccurate, and if you do not know by how much, your list of dormant accounts is wrong.
There are two attributes. lastLogon is accurate but is not replicated — each domain controller keeps its own value, so reading it from one DC tells you only about logons that DC handled. lastLogonTimestamp is replicated, which is why scripts use it, but it is only updated when the existing value is older than the current time minus msDS-LogonTimeSyncInterval. Microsoft calculates that as 14 days minus a random percentage of 5 days.
So a replicated timestamp can trail reality by nine to fourteen days. Against a 180-day dormancy threshold that is noise. Against a 30-day threshold it is the difference between an account that is genuinely dead and one whose owner was on holiday.
# accurate answer: ask every DC for its own lastLogon and keep the newest
$dcs = (Get-ADDomainController -Filter *).HostName
$users = Get-ADUser -Filter { Enabled -eq $true } -Properties lastLogonTimestamp
$users | ForEach-Object {
$u = $_
$newest = ($dcs | ForEach-Object {
(Get-ADUser $u.DistinguishedName -Server $_ -Properties lastLogon).lastLogon
} | Measure-Object -Maximum).Maximum
[PSCustomObject]@{
Name = $u.SamAccountName
TrueLastLogon = if ($newest) { [DateTime]::FromFileTime($newest) } else { $null }
Replicated = if ($u.lastLogonTimestamp) {
[DateTime]::FromFileTime($u.lastLogonTimestamp) } else { $null }
}
} | Export-Csv .\user-logons.csv -NoTypeInformationOn a large domain this query is slow and hits every DC. Run it once, out of hours, and work from the CSV. Do not build it into a scheduled cleanup job.
For computers, the password age is the better signal
A domain-joined machine rotates its own computer account password every 30 days by default, whether or not anyone logs on to it. That makes pwdLastSet a far more reliable indicator of a live machine than any logon attribute: a server that nobody signs into still changes its password, and a laptop in a drawer does not.
# machines that have not changed their own password in 90 days
Get-ADComputer -Filter { Enabled -eq $true } -Properties pwdLastSet, OperatingSystem, `
lastLogonTimestamp, Description |
Select-Object Name, OperatingSystem, Description,
@{n='PwdLastSet';e={[DateTime]::FromFileTime($_.pwdLastSet)}} |
Where-Object { $_.PwdLastSet -lt (Get-Date).AddDays(-90) } |
Sort-Object PwdLastSet | Export-Csv .\stale-computers.csv -NoTypeInformationObjects that look dead and are not
This is the table to read before deleting anything. Every row is an object that a staleness query will flag and that you should leave alone.
| Object | Why it looks stale | What happens if you remove it |
|---|---|---|
| Cluster name object (CNO) and virtual computer objects (VCO) | Never logs on interactively | The cluster or one of its roles fails to come online at the next failover |
| krbtgt | Password appears ancient, never logs on | Kerberos stops working domain-wide. Only ever touch it via the documented double-reset procedure |
| krbtgt_NNNNN (one per RODC) | Same as above | That read-only DC can no longer issue tickets |
| Trust accounts (name ends in $) | No interactive logon | The trust with the other domain breaks |
| Entra Connect / Azure AD Connect sync account | Service account, no interactive use | Directory synchronisation stops |
| Accounts used only by appliances over LDAP bind | NAS, printers, scanners, door systems — no Windows logon | The device silently stops authenticating users |
| Service accounts for scheduled tasks on one server | Logon type does not update the usual attributes | A job that runs monthly or quarterly fails, long after the change |
| Computer objects for clustered file servers and print servers | Role objects, not machines | Shares or printers become unreachable by their published name |
The quarterly job is the one that catches people. A cleanup in March looks completely successful until the quarter-end process in June cannot authenticate, by which point nobody connects the two events.
Group nesting, and the failure it eventually causes
Nested groups are not a problem in themselves. Accumulated nesting is, because Kerberos puts every group SID a user holds into the ticket, and the ticket has a size limit.
MaxTokenSize defaults to 48,000 bytes on Windows Server 2012 and later. Microsoft's guidance is that a user in more than roughly 120 universal groups will not fit. When that happens the symptom is not "you are in too many groups" — it is an HTTP 400 "Request Header too long" from a web application, or Group Policy quietly failing to apply. NTLM keeps working, so the failure looks application-specific and intermittent, and gets investigated as an application bug for weeks.
# users with the most group memberships, transitive nesting included
Get-ADUser -Filter { Enabled -eq $true } | ForEach-Object {
$n = (Get-ADPrincipalGroupMembership $_).Count
[PSCustomObject]@{ User = $_.SamAccountName; Groups = $n }
} | Sort-Object Groups -Descending | Select-Object -First 40
# groups nested inside other groups — where the sprawl actually lives
Get-ADGroup -Filter * -Properties MemberOf, Members |
Where-Object { $_.MemberOf.Count -gt 0 } |
Select-Object Name, @{n='NestedInto';e={$_.MemberOf.Count}},
@{n='Members';e={$_.Members.Count}} |
Sort-Object NestedInto -DescendingCheck for circular nesting at the same time — group A inside B inside A. It is legal, it resolves, and it makes every attempt to reason about effective access pointless.
Privilege nobody granted on purpose
Domain Admins is the group everybody checks. The interesting findings are in the ones nobody looks at, because membership of any of them is effectively equivalent to domain compromise: Account Operators, Backup Operators, Server Operators, Print Operators, Schema Admins, Enterprise Admins and DnsAdmins.
Then there is the residue. When an account is added to a protected group, AdminSDHolder stamps it with adminCount=1 and disables inheritance on the object's ACL. Removing the account from the group later does not undo either. So a domain that has been running for a decade contains user objects with broken permission inheritance and an adminCount flag, belonging to people who have not been administrators since 2018 — invisible in any group membership report, because they are not in the group any more.
# current members of every protected group, resolved transitively
'Domain Admins','Enterprise Admins','Schema Admins','Account Operators',
'Backup Operators','Server Operators','Print Operators','DnsAdmins' | ForEach-Object {
$g = $_
try {
Get-ADGroupMember $g -Recursive |
Select-Object @{n='Group';e={$g}}, SamAccountName, objectClass
} catch { }
}
# the residue: flagged as privileged, no longer in any privileged group
Get-ADObject -LDAPFilter '(adminCount=1)' -Properties adminCount, sAMAccountName |
Select-Object Name, sAMAccountName, ObjectClassDo not bulk-clear adminCount. Every object on that list needs deciding individually: some are current administrators and the flag is correct, some are residue and clearing it plus re-enabling inheritance is right. A script that clears all of them will re-enable inheritance on accounts that are deliberately protected.
Service accounts
Four things to look for, in rising order of how much trouble they cause:
- •Password never expires. Common and often unavoidable, but it should be a deliberate list you can name, not a default nobody chose.
- •A service principal name set on a normal user account. Any authenticated user can request a ticket for it and attack the hash offline — so a weak password on an SPN-bearing account is a domain problem, not a service problem.
- •Kerberos pre-authentication disabled. Rarely needed, and it lets anyone request crackable material for that account without credentials at all.
- •One account shared by several services across several servers. It cannot be rotated without an outage of unknown scope, which is why it never is.
Get-ADUser -Filter { Enabled -eq $true } `
-Properties ServicePrincipalName, PasswordNeverExpires, `
DoesNotRequirePreAuth, PasswordLastSet |
Where-Object { $_.ServicePrincipalName -or $_.DoesNotRequirePreAuth } |
Select-Object SamAccountName, PasswordNeverExpires, DoesNotRequirePreAuth,
PasswordLastSet, @{n='SPNs';e={$_.ServicePrincipalName -join '; '}} |
Export-Csv .\service-accounts.csv -NoTypeInformationGroup managed service accounts solve most of this properly: the domain rotates the password, nobody knows it, and it cannot be reused interactively. Not everything supports them, but more does than most estates have adopted.
Removing things without causing an outage
The audit is the easy half. The removal is where cleanups go wrong, and the safe procedure is slower than anyone wants it to be.
- •Enable the AD Recycle Bin first, if it is not already on. It makes an accidental deletion recoverable with attributes and group memberships intact. Note that once enabled it cannot be turned off.
- •Disable, do not delete. A disabled account that turns out to be load-bearing is a five-minute fix; a deleted one is an incident.
- •Be careful with the quarantine OU. Moving objects changes which Group Policy applies to them, which is a second change landing at the same time as the first. If you use one, block inheritance on it deliberately and know what that does.
- •Wait a full business cycle before deleting. A month catches monthly jobs; a quarter catches quarter-end. This is the step everyone truncates and the reason cleanups surface as incidents months later.
- •Delete in small batches, with a record of what was removed and when, so the next failure can be correlated against it.
For file and share permissions specifically, the orphaned SIDs left behind by user deletion are a related problem with its own procedure — see file server migration without breaking permissions for the ACL side of the same cleanup.
What this is worth
An AD audit is not urgent, which is exactly why it does not happen. It becomes urgent in one of three ways: an auditor asks who has administrative access and the honest answer takes a week to assemble; an incident happens and the blast radius turns out to be larger than anyone assumed; or a migration or tenant sync forces someone to finally look.
Doing it before one of those is considerably cheaper than doing it during.
For the file and share side of the same cleanup there is a printable file server migration checklist, free and not gated.
Want to know what is actually in your directory?
We run Active Directory audits and remediation, plus ongoing Windows and Linux server administration. €65/hour, fixed-scope quotes for projects, from €60/month per server for ongoing support.