- Published on
How to Find Inactive User Accounts in Active Directory (PowerShell Script)
Disabled accounts get attention because someone remembered to disable them. The bigger risk is usually the account that's still enabled and just... hasn't been used in months. Nobody flags it because nothing about it looks wrong — until it's the account that gets used in an incident nobody notices for weeks.
The quick answer
Get-ADUser -Filter {Enabled -eq $true} -Properties LastLogonDate |
Where-Object { -not $_.LastLogonDate -or $_.LastLogonDate -lt (Get-Date).AddDays(-90) }
A more useful reporting script
#requires -Modules ActiveDirectory
<#
.SYNOPSIS
Lists Active Directory user accounts that haven't logged on recently.
.DESCRIPTION
Queries Active Directory for enabled user accounts whose LastLogonDate is
older than the given threshold (or who have never logged on at all),
optionally scoped to an OU, and optionally exporting the results to CSV.
LastLogonDate is a replicated attribute and can lag the true last logon
by up to the domain's replication interval — good enough for a stale-
account cleanup pass, not for security-incident-grade precision. Uses the
current logged-on user's credentials — run it from a domain-joined
machine with RSAT installed.
.PARAMETER Days
Flag users who haven't logged on in at least this many days. Defaults to 90.
.PARAMETER SearchBase
Distinguished name of the OU to search (e.g. "OU=Sales,DC=contoso,DC=com").
If omitted, searches the entire domain.
.PARAMETER OutputCsv
Path to a CSV file to export the results to. If omitted, results are only
written to the console.
.EXAMPLE
.\Find-InactiveADUsers.ps1
Lists enabled users who haven't logged on in 90+ days.
.EXAMPLE
.\Find-InactiveADUsers.ps1 -Days 30 -SearchBase "OU=Contractors,DC=contoso,DC=com"
Lists enabled users in the Contractors OU who haven't logged on in 30+ days.
#>
[CmdletBinding()]
param(
[int]$Days = 90,
[string]$SearchBase,
[string]$OutputCsv
)
Import-Module ActiveDirectory -ErrorAction Stop
$cutoff = (Get-Date).AddDays(-$Days)
$params = @{
Filter = { Enabled -eq $true }
Properties = @('LastLogonDate', 'DistinguishedName')
}
if ($SearchBase) {
$params['SearchBase'] = $SearchBase
}
$inactiveUsers = Get-ADUser @params |
Where-Object { -not $_.LastLogonDate -or $_.LastLogonDate -lt $cutoff } |
Select-Object Name, SamAccountName, LastLogonDate, DistinguishedName |
Sort-Object @{Expression = { $_.LastLogonDate } }
if (-not $inactiveUsers) {
Write-Host "No inactive user accounts found (threshold: $Days days)." -ForegroundColor Yellow
return
}
Write-Host "Found $(@($inactiveUsers).Count) inactive user account(s) (no logon in $Days+ days)." -ForegroundColor Cyan
$inactiveUsers | Format-Table Name, SamAccountName, LastLogonDate, DistinguishedName -AutoSize
if ($OutputCsv) {
$inactiveUsers | Export-Csv -Path $OutputCsv -NoTypeInformation
Write-Host "Exported results to $OutputCsv" -ForegroundColor Green
}
WARNING
LastLogonDate is replicated between domain controllers and only updates a few times a day by default (msDS-LogonTimeSyncInterval), so it can lag reality by up to that interval. Good enough for a stale-account sweep; not precise enough to prove an account didn't log on at a specific moment. For that, check LastLogon (not replicated) across every DC and take the max — see our true last-logon script.
Where the manual approach runs out of road
A one-off script is fine for a single check. It starts to hurt once you actually need to run this regularly:
- No scheduling. Cron/Task Scheduler can run the script, but now you own the scheduling, the credentials it runs as, and what happens when it silently fails.
- No history. A CSV export is a snapshot. Was this the same five accounts as last month, or a growing list? A single export can't tell you.
- No distribution. Getting the report to the right people (security, IT ops, compliance) on a schedule means building that plumbing yourself.
- Multi-domain/multi-forest pain. Run it once per domain, reconcile the results yourself, and hope naming/OU conventions are consistent across all of them.
- No alerting. If something changes unexpectedly between runs — an account re-enabled, a privileged group gaining a member — nothing tells you until you happen to run the script again.
None of that is a PowerShell problem. It's what turns a script into a product.
What SysFlint AD does instead
Our SysFlint AD runs the same kind of discovery shown above automatically, on a schedule, entirely inside your own network. No agents on domain controllers, no data leaving your environment. It's free, forever.
- Stale and inactive account detection — Enabled accounts that nobody has logged into for 30, 60, or 90+ days — the ones that are still a live attack surface.
- Scheduled reports by email — Daily, weekly, or monthly runs delivered to the right inbox automatically. No Task Scheduler job for you to babysit.
- Multi-domain and forest coverage — Enumerate every domain in the forest in one pass and get one consolidated report instead of one per domain.
If you're currently doing this with a script on a scheduled task, here's the honest comparison between the two, or go straight to the download page.
Get this script, and the rest of them
The script above is part of awesome-it-scripts, SysFlint's free, open-source library of PowerShell scripts for Active Directory and other IT-admin tasks — no signup, no catch, MIT licensed. Grab this one directly from active-directory/Find-InactiveADUsers.ps1, or browse the whole thing.
Find every disabled/locked-out/stale/privileged-account script we've published (plus the FAQ that goes with each one) in the repo's active-directory folder. Star it if it's useful — new scripts land there regularly.