Field note: auto-disabling inactive AD accounts, and why the first run was report-only
An audit wanted inactive accounts disabled automatically. The first dry run flagged 65 accounts, including a domain trust account and a pile of VPN accounts that were rarely used but very much needed.
An audit at a small agency came back with a finding I’ve seen before: inactive and expired user accounts weren’t being disabled automatically. That’s a fair finding. Old accounts are an easy way in for an attacker or a former employee, and nobody reliably remembers to clean them up by hand.
The catch is that Active Directory has no built-in “disable after 90 days of inactivity” setting. You get the data to find stale accounts, but acting on it is up to you. So it became a script.
What the first run turned up
I wrote the script to check three things on every enabled user account with Get-ADUser:
- LastLogonDate, to find accounts that hadn’t signed in for 90 days
- whenCreated, so a brand-new account that hasn’t signed in yet doesn’t get caught
- AccountExpirationDate, to catch accounts that had already expired
Then I ran it in report-only mode. It changed nothing. It just wrote out what it would have disabled.
It flagged 65 accounts. Reading through the list is where the real work was:
- A domain trust account. It showed up as a user named after the other domain with a
$on the end, withuserAccountControlset to 2080. That’s 2048 (INTERDOMAIN_TRUST_ACCOUNT) plus 32 (PASSWD_NOTREQD). It isn’t a person and it never “logs on” the way a person does, so to an inactivity check it looks like the stalest account in the building. It belongs to the trust, and disabling it would have been a very bad way to find that out. - Dozens of VPN accounts used only occasionally by field staff. They might go months between uses and then be needed urgently.
- Vendor and service accounts that sign in rarely or never interactively but still matter.
If the first run had been the enforcing run, all of those would have been disabled at once.
What the script ended up skipping
- Trust accounts, by checking the 2048 bit:
userAccountControl -band 2048 - The built-in Administrator (RID 500) and krbtgt (RID 502). Matching on the RID instead of the name means a renamed Administrator account still gets skipped.
- Anything in an exemption security group. The VPN accounts and service accounts went in there.
I liked the group approach because an auditor can see it. “Who’s exempt and why” is a group membership you can print, not a list buried in a script.
Here’s a stripped-down sketch of the selection logic. It isn’t the production script, the group name is an example, and it only reports:
$cutoff = (Get-Date).AddDays(-90)
$domainSid = (Get-ADDomain).DomainSID.Value
$exempt = (Get-ADGroupMember 'Inactive-Disable-Exempt' -Recursive).SID.Value
Get-ADUser -Filter 'Enabled -eq $true' -Properties LastLogonDate, whenCreated, AccountExpirationDate, userAccountControl |
Where-Object {
-not ($_.userAccountControl -band 2048) -and # interdomain trust account
$_.SID.Value -ne "$domainSid-500" -and # built-in Administrator
$_.SID.Value -ne "$domainSid-502" -and # krbtgt
$_.SID.Value -notin $exempt -and
(
($_.AccountExpirationDate -and $_.AccountExpirationDate -lt (Get-Date)) -or
((-not $_.LastLogonDate -or $_.LastLogonDate -lt $cutoff) -and $_.whenCreated -lt $cutoff)
)
} |
Select-Object SamAccountName, LastLogonDate, whenCreated, AccountExpirationDate, Description
One thing to know about LastLogonDate: it’s calculated from the replicated lastLogonTimestamp attribute, which by design only updates every 9 to 14 days with default settings. That’s fine for a 90-day rule, but don’t use it for anything tighter than a couple of weeks.
The enforced run
Once the list made sense, the script went live. For every account it disables, it:
- writes a line to a CSV log (who, when, and why)
- stamps the account’s description so anyone opening it in Active Directory Users and Computers can see what happened
The enforced run disabled 29 accounts. It’s now a scheduled task that runs daily as SYSTEM on a domain controller.
If something turns out to be needed, rollback is one command per account, and the CSV says exactly which accounts to run it on:
Enable-ADAccount -Identity <SamAccountName>
Disabling instead of deleting is what makes that possible. Microsoft’s own guidance for stale accounts follows the same order: disable, wait several weeks, then delete if nobody complains.
What I took away from it
- Always dry-run before you enforce. The report-only pass caught a trust account and dozens of needed VPN accounts before anything broke.
- Trust accounts look like “never logged on” users. Filter them out by the
INTERDOMAIN_TRUST_ACCOUNTbit, not by name. - Put exemptions in a group an auditor can see, not in a hard-coded list.
- A scream test is fine when every change is logged and reversible. Disable, log it, stamp it, and keep the re-enable command handy.
For more on keeping admin accounts tidy, see separate admin accounts and the Protected Users group.
Sources
- Microsoft: Regularly check for and remove inactive user accounts in Active Directory: why stale accounts are a risk, and disable first, wait, then delete
- Microsoft (Ask the Directory Services Team): The lastLogonTimestamp attribute: built to find inactive accounts; 9 to 14 days behind by default
- Microsoft TechNet Wiki: LastLogon, LastLogonTimestamp and LastLogonDate: LastLogonDate is a converted copy of lastLogonTimestamp
- Microsoft: UserAccountControl property flags:
INTERDOMAIN_TRUST_ACCOUNT= 2048,PASSWD_NOTREQD= 32 - Microsoft [MS-ADTS]: Essential attributes of interdomain trust accounts: the account is named after the partner domain’s NetBIOS name plus
$and lives in the default Users container - Microsoft: Security identifiers: RID 500 (Administrator) and RID 502 (KRBTGT)
- Microsoft Learn: Get-ADUser, Get-ADGroupMember, Disable-ADAccount, Enable-ADAccount, Set-ADUser
From Small Town Sysadmin
Get new guides by email
Plain-English tech tips, scam and security alerts, and fixes for small offices, whether you're the office manager or the IT pro. No spam, and you can unsubscribe anytime.