In partnership with

THE PROBLEM

Every Active Directory environment accumulates ghosts. Users who left two years ago whose accounts are still enabled. Computer objects for machines that were decommissioned before you started. Service accounts nobody remembers creating. Each one is a valid credential sitting in your domain waiting to be used — and auditors will find them before you do. This week we build a script that finds every stale account and emails you a full report.

THE SCRIPT

Save the script below to:

C:\Scripts\StaleADAccounts.ps1

powershell

# Stale Active Directory Account Finder
# Automate & Operate — automateandoperate.com

Import-Module ActiveDirectory

# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)

$logFile = "C:\Logs\StaleADAccounts.log"
$userReportFile = "C:\Logs\StaleUsers.csv"
$computerReportFile = "C:\Logs\StaleComputers.csv"

# Days of inactivity before an account is considered stale
$staleThresholdDays = 90

# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
    New-Item -ItemType Directory -Path "C:\Logs"
}

$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$cutoffDate = (Get-Date).AddDays(-$staleThresholdDays)

# ---------- STALE USER ACCOUNTS ----------
$staleUsers = @()

try {
    $users = Get-ADUser -Filter { Enabled -eq $true } -Properties LastLogonDate, PasswordLastSet, whenCreated, Description

    foreach ($user in $users) {
        $lastLogon = $user.LastLogonDate

        # Never logged on, or logged on before the cutoff
        if ($null -eq $lastLogon -or $lastLogon -lt $cutoffDate) {
            $daysInactive = if ($null -eq $lastLogon) {
                (New-TimeSpan -Start $user.whenCreated -End (Get-Date)).Days
            } else {
                (New-TimeSpan -Start $lastLogon -End (Get-Date)).Days
            }

            $staleUsers += [PSCustomObject]@{
                Name            = $user.Name
                SamAccountName  = $user.SamAccountName
                LastLogonDate   = if ($lastLogon) { $lastLogon.ToString("yyyy-MM-dd") } else { "NEVER" }
                DaysInactive    = $daysInactive
                PasswordLastSet = if ($user.PasswordLastSet) { $user.PasswordLastSet.ToString("yyyy-MM-dd") } else { "NEVER" }
                Description     = $user.Description
                DistinguishedName = $user.DistinguishedName
            }
        }
    }

    Add-Content -Path $logFile -Value "$timestamp — Scanned $($users.Count) enabled users — $($staleUsers.Count) stale"

} catch {
    Add-Content -Path $logFile -Value "$timestamp — ERROR scanning users — $_"
}

# ---------- STALE COMPUTER ACCOUNTS ----------
$staleComputers = @()

try {
    $computers = Get-ADComputer -Filter { Enabled -eq $true } -Properties LastLogonDate, OperatingSystem, whenCreated

    foreach ($computer in $computers) {
        $lastLogon = $computer.LastLogonDate

        if ($null -eq $lastLogon -or $lastLogon -lt $cutoffDate) {
            $daysInactive = if ($null -eq $lastLogon) {
                (New-TimeSpan -Start $computer.whenCreated -End (Get-Date)).Days
            } else {
                (New-TimeSpan -Start $lastLogon -End (Get-Date)).Days
            }

            $staleComputers += [PSCustomObject]@{
                Name              = $computer.Name
                LastLogonDate     = if ($lastLogon) { $lastLogon.ToString("yyyy-MM-dd") } else { "NEVER" }
                DaysInactive      = $daysInactive
                OperatingSystem   = $computer.OperatingSystem
                DistinguishedName = $computer.DistinguishedName
            }
        }
    }

    Add-Content -Path $logFile -Value "$timestamp — Scanned $($computers.Count) enabled computers — $($staleComputers.Count) stale"

} catch {
    Add-Content -Path $logFile -Value "$timestamp — ERROR scanning computers — $_"
}

# ---------- EXPORT REPORTS ----------
$staleUsers | Sort-Object DaysInactive -Descending | Export-Csv -Path $userReportFile -NoTypeInformation
$staleComputers | Sort-Object DaysInactive -Descending | Export-Csv -Path $computerReportFile -NoTypeInformation

# ---------- BUILD EMAIL ----------
$emailBody = "Stale Active Directory Account Report — $timestamp`n"
$emailBody += "Domain controller: $env:COMPUTERNAME`n"
$emailBody += "Inactivity threshold: $staleThresholdDays days`n`n"
$emailBody += "SUMMARY:`n"
$emailBody += "=" * 60 + "`n"
$emailBody += "Stale user accounts:     $($staleUsers.Count)`n"
$emailBody += "Stale computer accounts: $($staleComputers.Count)`n`n"

if ($staleUsers.Count -gt 0) {
    $emailBody += "TOP 15 STALE USERS (longest inactive first):`n"
    $emailBody += "=" * 60 + "`n"
    $staleUsers | Sort-Object DaysInactive -Descending | Select-Object -First 15 | ForEach-Object {
        $emailBody += "User:          $($_.Name) ($($_.SamAccountName))`n"
        $emailBody += "Last Logon:    $($_.LastLogonDate)`n"
        $emailBody += "Days Inactive: $($_.DaysInactive)`n"
        $emailBody += "-" * 40 + "`n"
    }
}

if ($staleComputers.Count -gt 0) {
    $emailBody += "`nTOP 15 STALE COMPUTERS (longest inactive first):`n"
    $emailBody += "=" * 60 + "`n"
    $staleComputers | Sort-Object DaysInactive -Descending | Select-Object -First 15 | ForEach-Object {
        $emailBody += "Computer:      $($_.Name)`n"
        $emailBody += "Last Logon:    $($_.LastLogonDate)`n"
        $emailBody += "Days Inactive: $($_.DaysInactive)`n"
        $emailBody += "OS:            $($_.OperatingSystem)`n"
        $emailBody += "-" * 40 + "`n"
    }
}

$emailBody += "`nFull reports saved to:`n"
$emailBody += "  $userReportFile`n"
$emailBody += "  $computerReportFile`n`n"
$emailBody += "REVIEW BEFORE DISABLING. Some service accounts show no interactive logon.`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"

$totalStale = $staleUsers.Count + $staleComputers.Count

$subject = if ($totalStale -gt 0) {
    "AD AUDIT: $totalStale stale account(s) found — $($staleUsers.Count) users, $($staleComputers.Count) computers"
} else {
    "AD Audit Complete — No stale accounts found"
}

Send-MailMessage `
    -From $from -To $to `
    -Subject $subject `
    -Body $emailBody `
    -SmtpServer $smtpServer -Port $smtpPort `
    -UseSsl -Credential $credential

Update these lines:

  • $from / $to / $username / $password — your email details

  • $smtpServer — your SMTP server (e.g. smtp.office365.com)

  • $staleThresholdDays — 90 is a good default; drop to 30 for tighter environments

Requirements: Run this on a domain controller, or on a machine with RSAT and the ActiveDirectory module installed.

SETTING UP TASK SCHEDULER

  1. Press Windows key → type Task Scheduler → open it

  2. Click "Create Basic Task" → name it Stale AD Account Audit

  3. Trigger: Monthly → 1st of the month → 7:00am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

-ExecutionPolicy Bypass -File "C:\Scripts\StaleADAccounts.ps1"
  1. Click Next → Finish

Under Properties → General, check "Run with highest privileges" and set it to run under an account with AD read permissions.

TEST IT

Open PowerShell as administrator on a domain controller and run:

powershell

powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\StaleADAccounts.ps1"

Open C:\Logs\StaleUsers.csv — it will list every enabled account that hasn't logged in within your threshold, sorted worst-first. Same for StaleComputers.csv.

To see a broader picture on the first run, temporarily set $staleThresholdDays = 30. You will almost certainly get more results than you expect.

BEFORE YOU DISABLE ANYTHING

Two important caveats:

Service accounts often show no interactive logon at all even though they're actively in use. Check the Description field and confirm with the application owner before touching them.

LastLogonDate replicates slowly. It's accurate enough for a 90-day audit but don't rely on it for anything under 14 days.

Once you've reviewed the CSV, disabling in bulk is one line:

powershell

Import-Csv "C:\Logs\StaleUsers.csv" | ForEach-Object { Disable-ADAccount -Identity $_.SamAccountName }

Move the reviewed accounts to a "Disabled Users" OU and delete after 30 days if nobody complains.

WHY THIS MATTERS

Stale accounts are the quietest risk in your environment. They have valid credentials, they're often excluded from MFA rollouts, and nobody is watching them for suspicious logins because nobody knows they exist. Attackers specifically hunt for them because a dormant account generates no alerts when compromised. Running this monthly turns an invisible risk into a reviewed, documented list — which is exactly what an auditor wants to see.

THIS WEEK'S ACTION

Run it once against your domain today with the threshold at 90 days. Open the users CSV and sort by DaysInactive. Look at the top 10. If any of those accounts belong to people who left the company, you've just found your first real win from this issue.

Reply to this email if you hit any issues — I read every reply.

Automate & Operate — automateandoperate.com

Speak naturally. Send without fixing.

Wispr Flow turns your voice into clean, professional text you can send the moment you stop talking. Not rough transcription you have to clean up. Actual polished text — ready for email, Slack, or any app.

Speak the way you think. Go on tangents. Change your mind mid-sentence. Flow strips the filler, fixes the grammar, and gives you text that reads like you spent five minutes writing it.

89% of messages sent with zero edits. Millions of professionals use Flow daily, including teams at OpenAI, Vercel, and Clay. Works on Mac, Windows, and iPhone.