Field notes

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, with userAccountControl set 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_ACCOUNT bit, 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

All articles