# Find Inactive AD Users and Computers: PowerShell Cleanup in 5 Steps

Source: https://srvscripts.com/guides/find-inactive-ad-users-computers/
Updated: 2026-10-03
Publisher: srvScripts (https://srvscripts.com/)

To find inactive AD users and computers reliably, you need to know which logon attribute you are reading, how far behind it can be, and which accounts must never be touched. This guide shows the PowerShell queries, the exclusions for service accounts, a disable-then-delete workflow with a quarantine OU, CSV and HTML reports, and a scheduled task that keeps the directory clean on Windows Server 2016 to 2025.

**Short answer:** Run `Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -UsersOnly` (or `-ComputersOnly`) and filter out disabled and excluded accounts. Disable the results and move them to a quarantine OU, wait 30 to 60 days, and only then delete them. Use a threshold of at least 30 days, because `lastLogonTimestamp` can lag by up to about two weeks.

In short: Run Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -UsersOnly (or -ComputersOnly) and filter out disabled and excluded accounts.

## Which method to use

| Method | Attribute used | Pros | Cons |
| --- | --- | --- | --- |
| Search-ADAccount -AccountInactive | LastLogonDate (from lastLogonTimestamp) | One command; users and computers | Few filter options |
| Get-ADUser / Get-ADComputer with a date filter | LastLogonDate, whenCreated, PasswordLastSet | Full control; exclusions in the query | More code |
| lastLogon on every DC | lastLogon (not replicated) | Exact date | Slow on large domains; one query per DC |
| Microsoft Entra sign-in activity | signInActivity in Microsoft Graph | Sees cloud-only sign-ins | Separate licensing and Graph permissions |

For a monthly cleanup, `LastLogonDate` is accurate enough. Use `lastLogon` across all DCs only when you must prove the exact last logon of a specific account.

## How lastLogonTimestamp works

Every DC records a user’s logon in the non-replicated `lastLogon` attribute. To keep replication traffic low, the DC only updates the replicated `lastLogonTimestamp` when the stored value is older than `msDS-LogonTimeSyncInterval` (14 days by default) minus a random offset. In practice the value you read can be roughly 9 to 14 days older than the real last logon.

- A threshold of 14 days or less produces false positives. Use 60 or 90 days for users and 90 days for computers.

- Do not lower `msDS-LogonTimeSyncInterval` just for reporting; it increases replication on every DC.

- Hybrid users who work only in Microsoft 365 with password hash synchronisation authenticate in the cloud, not against a DC, so their on-premises logon date may stay old while they are active. Check their Entra sign-in activity before you disable them.

## Agree the thresholds first

Write the rules down and get them approved by HR and security before the first run. A starting point that works for most organisations:

| Object type | Inactive after | Action | Delete after |
| --- | --- | --- | --- |
| Employee user accounts | 90 days | Disable, move to quarantine, notify manager | 60 days in quarantine |
| Contractor and guest-style accounts | 30–60 days, or the account expiry date | Disable, move to quarantine | 30 days |
| Workstations and laptops | 90 days | Disable, move to quarantine | 60 days (keep BitLocker keys until then) |
| Servers | Report only | Owner review | Manual decision |
| Service accounts | Excluded | Annual review with the application owner | Manual decision |

Leavers should be handled by your offboarding process on their last day; the inactivity cleanup is a safety net for accounts that process missed.

## Prerequisites

- The ActiveDirectory module (RSAT) and read access to the domain for reporting.

- For the cleanup steps, rights to disable, move and delete users and computers in the scoped OUs, and create rights in the quarantine OU.

- The Active Directory Recycle Bin enabled, so a deleted account can be restored with its groups.

- An agreed policy: inactivity threshold, exclusions, quarantine period and who approves deletions.

## Step 1: Find inactive AD users

The quickest way to find inactive AD users is `Search-ADAccount`. It returns disabled accounts too, so filter them out:

```
Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -UsersOnly -SearchBase "OU=Staff,DC=contoso,DC=com" |
    Where-Object Enabled |
    Select-Object Name, SamAccountName, LastLogonDate, DistinguishedName |
    Sort-Object LastLogonDate
```

The `Get-ADUser` version gives more control. It also catches accounts that were created more than 90 days ago and have never signed in, which have no `LastLogonDate` at all:

```
$cutoff = (Get-Date).AddDays(-90)
$users = Get-ADUser -Filter 'Enabled -eq $true -and whenCreated -lt $cutoff -and (LastLogonDate -lt $cutoff -or LastLogonDate -notlike "*")' -SearchBase "OU=Staff,DC=contoso,DC=com" -Properties LastLogonDate, whenCreated, PasswordLastSet, Description, servicePrincipalName, MemberOf
$users | Select-Object Name, SamAccountName, LastLogonDate, whenCreated, PasswordLastSet |
    Sort-Object LastLogonDate | Format-Table -AutoSize
```

Check `PasswordLastSet` as well: an account with an old logon date but a recent password change is often used by an application that authenticates in a way you do not expect.

### Exact last logon for doubtful accounts

Before you disable an important account that the report flags, read the non-replicated `lastLogon` value from every DC and keep the newest one:

```
function Get-ExactLastLogon([string]$Sam) {
    $newest = 0
    foreach ($dc in (Get-ADDomainController -Filter *).HostName) {
        $v = (Get-ADUser -Identity $Sam -Properties lastLogon -Server $dc).lastLogon
        if ($v -gt $newest) { $newest = $v }
    }
    if ($newest) { [datetime]::FromFileTime($newest) } else { 'Never' }
}
Get-ExactLastLogon -Sam jdoe
```

Run it only for a handful of accounts: it contacts every DC once per user.

### Check cloud sign-ins for hybrid users

If you find inactive AD users who hold Microsoft 365 licences, compare with their Entra sign-in activity. The `signInActivity` property needs Microsoft Entra ID P1 or P2 and the `AuditLog.Read.All` permission:

```
Connect-MgGraph -Scopes 'User.Read.All', 'AuditLog.Read.All' -NoWelcome
foreach ($u in $users) {
    $m = Get-MgUser -UserId $u.UserPrincipalName -Property UserPrincipalName, SignInActivity -ErrorAction SilentlyContinue
    [pscustomobject]@{
        User          = $u.SamAccountName
        ADLastLogon   = $u.LastLogonDate
        CloudLastSign = $m.SignInActivity.LastSuccessfulSignInDateTime
    }
}
```

An account with an old AD logon but a recent cloud sign-in is active; leave it alone and exclude it from the cleanup. `LastSuccessfulSignInDateTime` counts interactive and non-interactive sign-ins, so it is the right field for this check.

## Step 2: Find stale computers

Domain-joined Windows machines change their computer account password every 30 days by default. A computer with both an old `LastLogonDate` and an old `PasswordLastSet` has almost certainly not contacted the domain.

```
$cutoff = (Get-Date).AddDays(-90)
Get-ADComputer -Filter 'Enabled -eq $true -and LastLogonDate -lt $cutoff -and PasswordLastSet -lt $cutoff' -Properties LastLogonDate, PasswordLastSet, OperatingSystem, whenCreated, Description |
    Where-Object { $_.OperatingSystem -notlike '*Server*' } |
    Select-Object Name, OperatingSystem, LastLogonDate, PasswordLastSet, DistinguishedName |
    Sort-Object LastLogonDate
# Same idea with Search-ADAccount
Search-ADAccount -AccountInactive -TimeSpan 90.00:00:00 -ComputersOnly | Where-Object Enabled
```

Treat servers separately: a file server that is only accessed, never restarted, still updates its password, but cluster name objects, NAS appliances, storage accounts joined to AD and non-Windows systems may not behave like Windows clients. Confirm with the owner before disabling any server object.

## Step 3: Exclude service and special accounts

Most cleanup incidents come from disabling an account that an application uses once a quarter. Build the exclusions into the script, not into someone’s memory:

- **By OU:** keep service accounts in `OU=Service Accounts` and never include it in `-SearchBase`.

- **By group:** create SEC-Cleanup-Exempt and skip its members.

- **By attribute:** skip accounts with a `servicePrincipalName`, or with a marker such as “SVC” in the description.

- **Built-in accounts:** skip the built-in Administrator (RID 500), Guest and `krbtgt`.

- **Managed service accounts:** gMSAs and sMSAs have their own object classes; keep them out of user queries.

```
$exemptDn = (Get-ADGroup -Identity 'SEC-Cleanup-Exempt').DistinguishedName
$candidates = $users | Where-Object {
    $_.MemberOf -notcontains $exemptDn -and
    -not $_.servicePrincipalName -and
    $_.Description -notmatch 'SVC' -and
    $_.SID.Value -notmatch '-(500|501|502)$' -and
    $_.ObjectClass -eq 'user'
}
$candidates.Count
```

RIDs 500, 501 and 502 are the built-in Administrator, Guest and `krbtgt`. The `MemberOf` check covers direct membership only; keep the exempt group flat.

Run this block each time you find inactive AD users so the exclusions are applied the same way every month.

## Step 4: Disable and quarantine

Deleting straight away is the most common mistake. The safer workflow is: report, disable, move to a quarantine OU, wait, then delete. Create the OU once:

```
New-ADOrganizationalUnit -Name 'Quarantine' -Path 'DC=contoso,DC=com' -ProtectedFromAccidentalDeletion $true
New-ADOrganizationalUnit -Name 'Users' -Path 'OU=Quarantine,DC=contoso,DC=com'
New-ADOrganizationalUnit -Name 'Computers' -Path 'OU=Quarantine,DC=contoso,DC=com'
```

Then disable each candidate, record the date and the original OU in the `info` (Notes) attribute, and move it. The script supports `-WhatIf`:

```
[CmdletBinding(SupportsShouldProcess)]
param([Parameter(Mandatory)][object[]]$Objects, [string]$TargetOU = 'OU=Users,OU=Quarantine,DC=contoso,DC=com')
$today = Get-Date -Format 'yyyy-MM-dd'
foreach ($o in $Objects) {
    $parent = $o.DistinguishedName -replace '^CN=.+?(?
