Emergency server help: get in touch

Inactive AD Accounts Report: PowerShell Script with Safe Disable

Free PowerShell script that reports AD users and computers inactive for N days (lastLogonTimestamp), with never-logged-on detection and a safe -WhatIf disable.

Version
1.0.0
Last updated
October 6, 2026
Language
PowerShell
Tested on
Run on 6 Oct 2026 on a Windows Server 2025 DC (build 26100.33438, Windows PowerShell 5.1) in our lab domain with a Windows 11 Pro member; syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25.
License
MIT
Pricing
Free

Short answer: run .\Get-ADInactiveAccounts.ps1 -Days 90 -ExportCsv C:\Reports\inactive.csv to list enabled users and computers whose lastLogonTimestamp is older than 90 days. Add -IncludeNeverLoggedOn to catch accounts that were created more than 90 days ago and never used. When you have reviewed the list, -Disable -WhatIf shows what would be disabled and -Disable does it, always skipping critical system objects and anything you -Exclude.

We ran this script on 6 October 2026 on a Windows Server 2025 Standard domain controller (build 26100.33438, September 2026 update, Windows PowerShell 5.1) and a Windows 11 Pro member PC in our lab domain contoso.com. The example output below is from that run; the lab domain is small and new, so your numbers will be bigger. It was also syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25.

What it does

Stale accounts are an easy win for both security and licensing. This script is the ready-to-run version of the method in our guide Find inactive AD users and computers: the same lastLogonTimestamp logic, with parameters, a CSV and a guarded disable step.

  • Server-side filter. The domain controller evaluates (lastLogonTimestamp<=<cutoff>), so only stale accounts come back, even in large domains.
  • Never logged on, but not brand new. -IncludeNeverLoggedOn adds accounts with no lastLogonTimestamp whose whenCreated is also older than the cutoff, so last week’s new starters are not flagged.
  • Users, computers or both, enabled only by default (-IncludeDisabled to see the disabled ones too).
  • Useful columns: last logon date, days inactive, created date, password last set, operating system for computers, description.
  • Safe disable. -Disable supports -WhatIf and -Confirm (it asks for each account by default), never touches objects marked isCriticalSystemObject (for example the built-in Administrator and domain controller computer accounts), honours -Exclude patterns, and records the result per account in the Action column.

Requirements

  • Windows PowerShell 5.1 or PowerShell 7 on Windows, with the ActiveDirectory module (RSAT).
  • Read access to AD for the report (the default for domain users).
  • For -Disable: the right to disable the accounts in scope, for example delegated on the OUs.
  • Domain functional level Windows Server 2003 or later, which is when lastLogonTimestamp is maintained (any current domain).

Download and first run

  1. Copy the script from the box on this page (or use the download button) and save it as C:\Scripts\Get-ADInactiveAccounts.ps1.
  2. If you downloaded the file, remove the “downloaded from the internet” mark so the execution policy lets it run: Unblock-File C:\Scripts\Get-ADInactiveAccounts.ps1.
  3. Run it on a domain-joined machine that has the ActiveDirectory module: a domain controller, a management server, or a Windows 11 PC with RSAT (see Install RSAT on Windows 11).
  4. Read the built-in help once, then run it with no options to see the result on screen before you export anything.
cd C:\Scripts
Get-Help .\Get-ADInactiveAccounts.ps1 -Full
.\Get-ADInactiveAccounts.ps1 -Days 90
.\Get-ADInactiveAccounts.ps1 -Days 90 -IncludeNeverLoggedOn -ExportCsv C:\Reports\inactive.csv

Options

ParameterWhat it doesDefault
-DaysInactivity threshold in days (15 to 3650)90
-ObjectTypeUsers, Computers or BothBoth
-IncludeNeverLoggedOnAdd never-used accounts older than -DaysOff
-IncludeDisabledAlso list accounts that are already disabledOff
-SearchBaseLimit to one OU (DN)Whole domain
-ExcludesAMAccountName patterns to leave out, e.g. "svc-*","SQL01$"None
-ServerDomain or DC to queryCurrent domain
-DisableDisable the reported enabled accounts (with -WhatIf / -Confirm)Off
-ExportCsvWrite a CSV fileNot written
-PassThruSend the row objects down the pipelineOff

The minimum of 15 days is deliberate: lastLogonTimestamp can lag the real last logon by up to 14 days, so a shorter threshold would flag active people.

Usage examples

# Users and computers with no logon for 90 days, to CSV
.\Get-ADInactiveAccounts.ps1 -Days 90 -ExportCsv C:\Reports\inactive.csv

# Workstations idle for 120 days, including ones that never joined properly
.\Get-ADInactiveAccounts.ps1 -ObjectType Computers -Days 120 -IncludeNeverLoggedOn -SearchBase "OU=Workstations,DC=contoso,DC=com"

# Preview a disable run, leaving service accounts alone
.\Get-ADInactiveAccounts.ps1 -ObjectType Users -Days 120 -Exclude "svc-*" -Disable -WhatIf

# Disable without per-account prompts, keeping a record
.\Get-ADInactiveAccounts.ps1 -ObjectType Users -Days 120 -Exclude "svc-*" -Disable -Confirm:$false -ExportCsv C:\Reports\disabled-2026-10.csv

# Stale servers (operating system contains "Server")
.\Get-ADInactiveAccounts.ps1 -ObjectType Computers -Days 60 -PassThru | Where-Object OperatingSystem -like '*Server*'

Before you disable anything

Disabling accounts can stop services. Service accounts, shared mailboxes’ users, break-glass admin accounts and computers that are only switched on occasionally (lab machines, spare laptops, DR servers) can all look inactive. Review the CSV with the account owners, add exceptions with -Exclude, and always run -Disable -WhatIf first.

  1. Export the list without -Disable and send it to the owners or managers for review.
  2. Make sure the AD Recycle Bin is on (see enable the AD Recycle Bin) so later deletions can be undone; disabling itself is reversible with Enable-ADAccount.
  3. Run with -Disable -WhatIf and check the “What if” lines.
  4. Run with -Disable -ExportCsv; the Action column records Disabled, Skipped or the error for every account.
  5. Leave disabled accounts for a grace period (30 to 90 days is common) before you move or delete them.

CSV columns

The example output further down is from our lab run. The columns are:

ColumnMeaning
Name, SamAccountName, ObjectTypeThe account; ObjectType is User or Computer
EnabledFrom userAccountControl
LastLogonDateFrom lastLogonTimestamp; empty if never logged on
DaysInactiveDays since LastLogonDate, or since creation if never logged on
NeverLoggedOnTrue if there is no lastLogonTimestamp
Created, PasswordLastSetFrom whenCreated and pwdLastSet
OperatingSystem, DescriptionHelpful when deciding about computers and service accounts
CriticalTrue for objects marked isCriticalSystemObject (never disabled)
DistinguishedNameFull DN
ActionEmpty, Disabled, Already disabled, Skipped: critical system object, WhatIf / skipped, or the error

Example output

With -Days 30 -IncludeNeverLoggedOn on the new lab domain nothing qualified yet, because every account was created the same day. The script compares against the creation date for never-used accounts, so a brand-new account is not reported as stale:

0 account(s) written to C:\srvs-lab\out\inactive.csv
Inactive for 30+ days (before 2026-09-06): 0 user(s), 0 computer(s).

Schedule it

Run it as a scheduled task so the report is waiting for you. A group managed service account (gMSA) is the cleanest identity for this: no password to store and nothing to expire. See our gMSA guide to create one and allow this server to retrieve its password.

$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Get-ADInactiveAccounts.ps1 -Days 90 -IncludeNeverLoggedOn -ExportCsv C:\Reports\inactive.csv'
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 7am
$principal = New-ScheduledTaskPrincipal -UserId 'CONTOSO\gmsa-adreport$' -LogonType Password
Register-ScheduledTask -TaskName 'AD inactive accounts report' -Action $action -Trigger $trigger -Principal $principal

Schedule the report, not the disable step. Automatic disabling without a human review is how service accounts and break-glass accounts get switched off by surprise.

How it works

  1. Works out the cutoff date and converts it twice: to a Windows file time for lastLogonTimestamp and to LDAP generalized time (yyyyMMddHHmmss.0Z) for whenCreated.
  2. Builds one LDAP filter per object type, for example (&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(lastLogonTimestamp<=...)), and adds the never-logged-on branch when asked.
  3. Runs Get-ADObject -LDAPFilter with a page size of 500 and reads only the attributes it needs.
  4. Applies -Exclude, calculates days inactive and builds the rows.
  5. With -Disable, calls Disable-ADAccount for each eligible row after ShouldProcess approval.

Limitations

  • lastLogonTimestamp lags. By default it is 9 to 14 days behind the real last logon. That is why the threshold starts at 15 days.
  • Not every sign-in updates it. Activity that does not authenticate against AD (for example cloud-only sign-ins to Microsoft 365 for a synced user) does not move lastLogonTimestamp. Check Entra ID sign-in activity before disabling hybrid users.
  • Computers that are off for long periods (spares, DR) look inactive; exclude them or put them in an OU you do not scan.
  • One domain per run; use -Server for others.
  • Not yet run against a live domain (see the note at the top).

Official documentation: The lastLogonTimestamp attribute (Microsoft AskDS) · Search-ADAccount -AccountInactive · UserAccountControl flags

Related: Find Inactive AD Users and Computers: PowerShell Cleanup in 5 Steps · Get-ADUser PowerShell Examples: 25 Queries for Active Directory · Enable and use the Active Directory Recycle Bin to restore deleted objects · Employee Offboarding Checklist: Secure AD, M365 and Workspace Steps · Install RSAT on Windows 11: 5 Methods, Including Offline

See also: Export AD Users to CSV: PowerShell Script with Last Logon · Locked Out AD Users Report: PowerShell Script with Lockout Source · AD Privileged Group Report: Domain Admins and adminCount Audit · AD Nested Group Membership: PowerShell Tree with Loop Detection · AD Health Check Report: dcdiag and repadmin PowerShell Script

The script

Get-ADInactiveAccounts.ps1Download
# Inactive AD Accounts Report: PowerShell Script with Safe Disable (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/inactive-ad-accounts-report/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
    Find AD user and computer accounts with no logon for N days (lastLogonTimestamp), report them to CSV and
    optionally disable them.

.DESCRIPTION
    Read-only unless -Disable is used.
    Finds enabled accounts whose lastLogonTimestamp is older than -Days, using an LDAP filter that the DC
    evaluates (no need to pull every account). With -IncludeNeverLoggedOn it also returns accounts that have
    never logged on (lastLogonTimestamp empty) and were created more than -Days ago, so brand-new accounts
    are not reported.

    lastLogonTimestamp is replicated to every DC but, by design, can be 9-14 days behind the real last
    logon (default ms-DS-Logon-Time-Sync-Interval of 14 days minus a random 0-5 days). Keep -Days well above
    14; 90 is the common choice.

    -Disable disables the reported accounts with Disable-ADAccount. It supports -WhatIf and -Confirm
    (ConfirmImpact High, so you are asked per account unless you pass -Confirm:$false). Objects marked
    isCriticalSystemObject (built-in Administrator, domain controller computer accounts, krbtgt ...) and
    anything matching -Exclude are never disabled. Export the CSV first: it is your record of what was changed.

.PARAMETER Days
    Inactivity threshold in days (default 90, minimum 15).

.PARAMETER ObjectType
    Users, Computers or Both (default Both).

.PARAMETER IncludeNeverLoggedOn
    Also report accounts that never logged on and are older than -Days.

.PARAMETER IncludeDisabled
    Also report accounts that are already disabled (they are never touched by -Disable).

.PARAMETER SearchBase
    Limit the search to this OU (DN).

.PARAMETER Exclude
    sAMAccountName patterns to leave out of the report and out of -Disable, e.g. "svc-*","SQL01$".

.PARAMETER Server
    Domain or DC to query (default: current domain).

.PARAMETER Disable
    Disable every reported, enabled account (except critical system objects and -Exclude matches).

.PARAMETER ExportCsv
    Write the report to this CSV file (UTF-8).

.PARAMETER PassThru
    Output the row objects to the pipeline.

.EXAMPLE
    .\Get-ADInactiveAccounts.ps1 -Days 90 -ExportCsv C:\Reports\inactive.csv

.EXAMPLE
    .\Get-ADInactiveAccounts.ps1 -ObjectType Computers -Days 120 -IncludeNeverLoggedOn -SearchBase "OU=Workstations,DC=contoso,DC=com"

.EXAMPLE
    .\Get-ADInactiveAccounts.ps1 -ObjectType Users -Days 120 -Exclude "svc-*" -Disable -WhatIf

.EXAMPLE
    .\Get-ADInactiveAccounts.ps1 -ObjectType Users -Days 120 -Exclude "svc-*" -Disable -Confirm:$false -ExportCsv C:\Reports\disabled-2026-10.csv

.NOTES
    Name:     Get-ADInactiveAccounts.ps1
    Version:  1.0.0
    Source:   https://srvscripts.com/scripts/inactive-ad-accounts-report/
    Guide:    https://srvscripts.com/guides/find-inactive-ad-users-computers/
    License:  MIT
    Requires: Windows PowerShell 5.1 or PowerShell 7 on Windows, ActiveDirectory module (RSAT), read access to AD;
              for -Disable, the right to disable the accounts in scope.
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
    [ValidateRange(15, 3650)]
    [int]$Days = 90,

    [ValidateSet('Users', 'Computers', 'Both')]
    [string]$ObjectType = 'Both',

    [switch]$IncludeNeverLoggedOn,

    [switch]$IncludeDisabled,

    [ValidateNotNullOrEmpty()]
    [string]$SearchBase,

    [ValidateNotNullOrEmpty()]
    [string[]]$Exclude,

    [ValidateNotNullOrEmpty()]
    [string]$Server,

    [switch]$Disable,

    [ValidateNotNullOrEmpty()]
    [string]$ExportCsv,

    [switch]$PassThru
)

Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'

if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
    throw "The ActiveDirectory module is not installed. Install RSAT: Active Directory Domain Services tools, then run again."
}
Import-Module ActiveDirectory -Verbose:$false

function ConvertFrom-FileTimeValue {
    param($Value)
    if ($null -eq $Value) { return $null }
    $v = [int64]$Value
    if ($v -le 0 -or $v -eq [int64]::MaxValue) { return $null }
    [DateTime]::FromFileTime($v)
}

function Get-AttributeValue {
    param($Entity, [string]$Name)
    if ($Entity.PropertyNames -contains $Name) { return $Entity[$Name].Value }
    $null
}

$now = Get-Date
$cutoff = $now.AddDays(-$Days)
$cutoffFileTime = $cutoff.ToFileTimeUtc()
$cutoffGenTime = $cutoff.ToUniversalTime().ToString('yyyyMMddHHmmss') + '.0Z'

$classes = switch ($ObjectType) {
    'Users'     { @{ User = '(objectCategory=person)(objectClass=user)' } }
    'Computers' { @{ Computer = '(objectCategory=computer)' } }
    default     { [ordered]@{ User = '(objectCategory=person)(objectClass=user)'; Computer = '(objectCategory=computer)' } }
}
$enabledClause = if ($IncludeDisabled) { '' } else { '(!(userAccountControl:1.2.840.113556.1.4.803:=2))' }
$activity = "(lastLogonTimestamp<=$cutoffFileTime)"
if ($IncludeNeverLoggedOn) { $activity = "(|$activity(&(!(lastLogonTimestamp=*))(whenCreated<=$cutoffGenTime)))" }

$attrs = 'lastLogonTimestamp', 'whenCreated', 'pwdLastSet', 'userAccountControl', 'description', 'operatingSystem',
         'isCriticalSystemObject', 'sAMAccountName', 'name'
$q = @{ Properties = $attrs; ResultPageSize = 500 }
if ($SearchBase) { $q.SearchBase = $SearchBase }
if ($Server) { $q.Server = $Server }

$rows = [System.Collections.Generic.List[object]]::new()
foreach ($kind in $classes.Keys) {
    $filter = "(&$($classes[$kind])$enabledClause$activity)"
    Write-Verbose "$kind filter: $filter"
    try {
        $objs = @(Get-ADObject -LDAPFilter $filter @q)
    } catch [System.ArgumentException] {
        throw "Invalid -SearchBase or filter: $($_.Exception.Message)"
    }
    foreach ($o in $objs) {
        $sam = [string](Get-AttributeValue $o 'sAMAccountName')
        $skip = $false
        foreach ($pattern in $Exclude) { if ($sam -like $pattern) { $skip = $true; break } }
        if ($skip) { Write-Verbose "Excluded: $sam"; continue }
        $last = ConvertFrom-FileTimeValue (Get-AttributeValue $o 'lastLogonTimestamp')
        $uac = Get-AttributeValue $o 'userAccountControl'
        $enabled = $null
        if ($null -ne $uac) { $enabled = -not ([int]$uac -band 2) }
        $created = Get-AttributeValue $o 'whenCreated'
        $inactiveDays = $null
        if ($last) { $inactiveDays = [int]($now - $last).TotalDays } elseif ($created) { $inactiveDays = [int]($now - $created).TotalDays }
        $rows.Add([pscustomobject]@{
            Name              = [string](Get-AttributeValue $o 'name')
            SamAccountName    = $sam
            ObjectType        = $kind
            Enabled           = $enabled
            LastLogonDate     = $last
            DaysInactive      = $inactiveDays
            NeverLoggedOn     = (-not $last)
            Created           = $created
            PasswordLastSet   = ConvertFrom-FileTimeValue (Get-AttributeValue $o 'pwdLastSet')
            OperatingSystem   = [string](Get-AttributeValue $o 'operatingSystem')
            Description       = [string](Get-AttributeValue $o 'description')
            Critical          = [bool](Get-AttributeValue $o 'isCriticalSystemObject')
            DistinguishedName = $o.DistinguishedName
            Action            = ''
        })
    }
}
$out = @($rows | Sort-Object ObjectType, @{ Expression = 'DaysInactive'; Descending = $true })

# ---- Optional disable -----------------------------------------------------------------------------------
if ($Disable) {
    $dArgs = @{}
    if ($Server) { $dArgs.Server = $Server }
    foreach ($r in $out) {
        if (-not $r.Enabled) { $r.Action = 'Already disabled'; continue }
        if ($r.Critical) { $r.Action = 'Skipped: critical system object'; continue }
        $what = if ($r.NeverLoggedOn) { 'never logged on' } else { "last logon $($r.LastLogonDate.ToString('yyyy-MM-dd'))" }
        if ($PSCmdlet.ShouldProcess("$($r.SamAccountName) ($($r.ObjectType), $what)", 'Disable-ADAccount')) {
            try {
                Disable-ADAccount -Identity $r.DistinguishedName @dArgs -Confirm:$false
                $r.Action = 'Disabled'
            } catch {
                $r.Action = "Disable failed: $($_.Exception.Message)"
                Write-Warning "Could not disable $($r.SamAccountName): $($_.Exception.Message)"
            }
        } else {
            $r.Action = 'WhatIf / skipped'
        }
    }
}

# ---- Output ---------------------------------------------------------------------------------------------
if ($ExportCsv) {
    $out | Export-Csv -LiteralPath $ExportCsv -NoTypeInformation -Encoding UTF8
    Write-Information ("{0} account(s) written to {1}" -f $out.Count, $ExportCsv) -InformationAction Continue
}
if ($PassThru) { return $out }
$users = @($out | Where-Object { $_.ObjectType -eq 'User' }).Count
$computers = @($out | Where-Object { $_.ObjectType -eq 'Computer' }).Count
Write-Information ("Inactive for {0}+ days (before {1:yyyy-MM-dd}): {2} user(s), {3} computer(s)." -f $Days, $cutoff, $users, $computers) -InformationAction Continue
if (-not $ExportCsv -and $out.Count) {
    $out | Format-Table Name, ObjectType, Enabled, LastLogonDate, DaysInactive, NeverLoggedOn, Action -AutoSize
}
Version 1.0.0 · SHA-256 cae68190a027a36f412677bde5176b5e431085c5ab5aa2600596b32219b3ebba
Download and verify on Linux or macOS
curl -fsSL -o Get-ADInactiveAccounts.ps1 https://scr.srvscripts.com/inactive-ad-accounts-report/Get-ADInactiveAccounts.ps1 && curl -fsSL https://scr.srvscripts.com/inactive-ad-accounts-report/Get-ADInactiveAccounts.ps1.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/inactive-ad-accounts-report/Get-ADInactiveAccounts.ps1' -OutFile 'Get-ADInactiveAccounts.ps1'; if ((Get-FileHash 'Get-ADInactiveAccounts.ps1' -Algorithm SHA256).Hash -eq 'CAE68190A027A36F412677BDE5176B5E431085C5AB5AA2600596B32219B3EBBA') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

How do I find inactive users in Active Directory with PowerShell?

Query enabled users whose lastLogonTimestamp is older than your cutoff. This script does it with a server-side LDAP filter: Get-ADInactiveAccounts.ps1 -ObjectType Users -Days 90.

How many days should count as inactive?

90 days is a common choice for users and computers. Do not go below about 15 days, because lastLogonTimestamp can be up to 14 days behind.

Why is an active user in the list?

They may only use services that do not authenticate against AD, such as cloud-only Microsoft 365 sign-ins, or they work on a schedule longer than your threshold. Add them to -Exclude.

Is it safe to disable inactive accounts automatically?

Not without review. Run the report, have owners confirm, then disable with -WhatIf first. Disabling is reversible with Enable-ADAccount.

What is the difference between this and Search-ADAccount -AccountInactive?

Both rely on lastLogonTimestamp. This script adds a clear never-logged-on rule based on the creation date, exclusions, extra columns and a guarded disable step with a CSV record.

Does it delete accounts?

No. It can only disable accounts, and only with -Disable. Deleting is left to you after a grace period.

Changelog

  • 1.0.0 — First release.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.