Practical engineering guidance
Retiring stale Active Directory accounts without breaking an application
Deleting a dormant account is easy. Knowing whether something still depends on it is the actual work, and LastLogonDate will not tell you.
Series: Active Directory
Every directory I have looked at contains accounts nobody can account for. They are a real security problem: dormant accounts with unchanged passwords and forgotten group membership are exactly what an attacker wants. They are also a real operational hazard, because some proportion of them are load-bearing in ways the directory does not record.
The mistake is to treat this as a deletion exercise. It is an evidence exercise that ends in deletion.
Requirements: what “stale” has to mean before you start
Agree a written definition with the directory owner before running anything. Mine usually has four parts:
- No interactive or network logon within an agreed window — 90 days for users is common, and a deliberately longer window for service accounts.
- Not created within that window, so new accounts awaiting a start date are excluded.
- Not on an explicit exclusion list: break-glass accounts, disaster recovery accounts, seasonal staff, long-term leave.
- Owner identified, or explicitly recorded as unknown.
That last point is the one that matters. An account you cannot attribute is not safe to delete or safe to keep, and the whole exercise exists to convert unknowns into decisions.
You also need an authorisation to do this, a change record, and a restore path. Confirm the Active Directory Recycle Bin is enabled before you delete anything — it converts a mistake from a restore-from-backup into a restore-from-directory.
The data model: which attribute actually answers the question
This is where most stale-account scripts go wrong, so it is worth being precise.
lastLogon is accurate but not replicated. Each domain controller records only the
logons it processed. Query one domain controller and you get one controller’s view, which
for a multi-site estate is close to meaningless.
lastLogonTimestamp is replicated, which is what makes it usable, but it is
deliberately imprecise. It only updates when the new value is older than the existing one by
more than a replication window derived from the ms-DS-LogonTimeSyncInterval attribute —
by default 14 days. So a value of “35 days ago” could mean a logon 35 days ago or as recently
as 21 days ago. LastLogonDate in the PowerShell module is this attribute, converted.
The practical consequence: lastLogonTimestamp is fine for finding accounts stale by
months, and unfit for any window shorter than about 30 days. Set your threshold with that
in mind. If you need precision, query lastLogon on every domain controller and take the
maximum — which is the correct method and considerably more expensive.
pwdLastSet is replicated and precise. An account whose password has not changed in
years, in a domain with a maximum password age, is either exempt from expiry or not being
used by a human.
whenCreated excludes new accounts. userAccountControl tells you whether the
account is already disabled, and whether DONT_EXPIRE_PASSWORD is set — which is a strong
hint that an account is a service account regardless of where it sits.
Collecting the evidence
Read-only, and collect more than you think you need. You will want the extra attributes when someone challenges a specific account.
$threshold = (Get-Date).AddDays(-90)
$properties = @(
'LastLogonDate', 'PasswordLastSet', 'whenCreated', 'Enabled',
'PasswordNeverExpires', 'ServicePrincipalName', 'Description',
'ManagedBy', 'MemberOf', 'CanonicalName'
)
Get-ADUser -Filter * -Properties $properties |
Where-Object {
($_.LastLogonDate -lt $threshold -or $null -eq $_.LastLogonDate) -and
$_.whenCreated -lt $threshold
} |
Select-Object Name, SamAccountName, Enabled, LastLogonDate, PasswordLastSet,
whenCreated, PasswordNeverExpires, Description, CanonicalName,
@{ Name = 'HasSPN'; Expression = { [bool]$_.ServicePrincipalName } },
@{ Name = 'GroupCount'; Expression = { @($_.MemberOf).Count } } |
Sort-Object LastLogonDate |
Export-Csv -Path 'C:\Temp\stale-users.csv' -NoTypeInformation -Encoding UTF8
$null -eq $_.LastLogonDate catches accounts that have never signed in at all, which is a
category worth separating in the review — they are usually either provisioning leftovers or
accounts used only as an identity, never for authentication.
For computers, the same shape with a longer window, because a machine that has been switched off over a quiet period is not stale:
Get-ADComputer -Filter * -Properties LastLogonDate, OperatingSystem, whenCreated, Enabled |
Where-Object { $_.LastLogonDate -lt (Get-Date).AddDays(-180) } |
Select-Object Name, Enabled, OperatingSystem, LastLogonDate, whenCreated |
Export-Csv -Path 'C:\Temp\stale-computers.csv' -NoTypeInformation -Encoding UTF8
Interpretation: the accounts that will hurt you
Sort your output into categories, because they carry very different risk.
Accounts with a service principal name. These are service accounts and a stale
LastLogonDate means very little. A service account used only for Kerberos ticket issuance
against a service may not generate the logon you are measuring. Treat any account with an
SPN as “do not touch until the service owner confirms”, and find the owner by looking at
what the SPN points to.
Accounts with PasswordNeverExpires. Overwhelmingly service accounts, application
identities or accounts created by a vendor’s installation guide. Same rule.
Accounts that are members of privileged groups. These are the highest-value finding in the whole exercise. A dormant account in a privileged group should be disabled today — but investigate before deleting, because some are legitimate break-glass accounts that are dormant precisely because they are working as intended.
Accounts referenced somewhere outside the directory. This is the category you cannot find with a directory query, and it is the one that breaks applications. A dormant account may be:
- configured as a scheduled task’s run-as account on a server;
- an IIS application pool identity;
- a SQL Server service or linked-server credential;
- stored in a connection string or a configuration file;
- the account a network device uses to bind to LDAP;
- the owner of a DNS record with secure dynamic update.
No Get-ADUser filter will find any of those. Which is why the retirement process cannot be
query-then-delete.
The retirement sequence
Use a staged process with a deliberate waiting period. The waiting period is the control that catches everything the query could not.
Stage 1 — Publish and wait. Send the list to account owners and service owners with a deadline. Some accounts get claimed. This is cheap and it removes the obvious mistakes.
Stage 2 — Disable, do not delete. Disable the account, move it to a quarantine organisational unit, and record the original location and group membership in the change record.
$ou = 'OU=Quarantine,OU=Retired,DC=contoso,DC=com'
$stamp = (Get-Date).ToString('yyyy-MM-dd')
Import-Csv 'C:\Temp\approved-for-retirement.csv' | ForEach-Object {
$user = Get-ADUser -Identity $_.SamAccountName -Properties Description, MemberOf
# Record where it came from before anything changes.
$note = "Retired $stamp. Was: $($user.DistinguishedName). Groups: " +
(($user.MemberOf | ForEach-Object { ($_ -split ',')[0] -replace '^CN=' }) -join ';')
Set-ADUser -Identity $user -Description $note -Enabled $false
Move-ADObject -Identity $user.DistinguishedName -TargetPath $ou
}
Work from an explicitly approved CSV, not from the discovery query. The gap between those two files is the review, and it is the part that keeps you employed.
Removing group membership at this stage is a judgement call. Removing it reduces risk immediately; keeping it makes reinstatement trivial. I usually remove privileged group membership immediately and leave ordinary membership until deletion, recording it either way.
Stage 3 — Wait through a full business cycle. Thirty days is a common choice; I prefer long enough to cover a month-end, a quarter-end and any annual process the organisation runs. A disabled account produces a clear authentication failure with the account name in it, which is exactly the signal you want. Failures during this window are the evidence the directory could not give you.
Stage 4 — Delete. Only accounts that completed the waiting period without incident. With the Recycle Bin enabled, a mistake here is recoverable for the duration of the deleted object lifetime.
Validation
After each stage, confirm the count moved as expected and nothing outside the approved list changed. Watch domain controller security logs for failed logons naming the disabled accounts during the waiting period — that is your break detector, and it needs somebody actually reading it rather than a report nobody opens.
Re-run the discovery query after completion to confirm the population dropped, and keep the original CSV, the approved CSV and the change record together. When somebody asks in eight months why an account is gone, that trio is the answer.
Operations: stop regenerating the problem
This exercise repeats forever unless the joiners-movers-leavers process improves. The
durable fixes are a leaver process that disables accounts on the last working day, service
accounts created with a recorded owner in ManagedBy and a description that says what uses
them, and group managed service accounts where the application supports them so the
credential is managed by the directory rather than by a person.
Making Description and ManagedBy mandatory at creation time costs nothing and removes
most of the archaeology from the next round.
Rollback
Before Stage 4, rollback is re-enabling the account, moving it back to the recorded organisational unit and restoring group membership from the change record — which is why the record is written before the change, not after. After Stage 4, rollback is a Recycle Bin restore, which returns the object with its attributes and link-valued membership intact. Without the Recycle Bin enabled, rollback after deletion is an authoritative restore from backup, which is a much larger operation. Confirm the Recycle Bin before you begin.
Verification and limits
Attribute replication behaviour, the lastLogonTimestamp imprecision window, Recycle Bin
prerequisites and cmdlet parameters were checked against current Microsoft documentation on
20 September 2026.
The scripts here were not executed against a directory for this article. The discovery queries are read-only and safe. The Stage 2 script modifies and moves accounts: run it against a test organisational unit first, confirm the Recycle Bin is enabled, and never run it directly against discovery output without a human review step in between. Exported CSV files contain account names and organisational structure — store them as you would any directory export, which is to say privately.
References
Reader feedback
Was this article useful?
No ratings yet. Be the first to rate this article.
