
Bulk-Disable and Archive Inactive AD Accounts with PowerShell
Before you run this
This procedure finds enabled user accounts that have not logged on for a set number of days, disables them, stamps a note in the account's description, and moves them into a dedicated "archive" OU so they are out of your working OUs but not deleted. Its purpose is routine account hygiene: a disabled, quarantined account can't be used for a logon, which shrinks your attack surface, but nothing is destroyed and everything is reversible.
It changes AD, so it needs privilege. You must run it from a machine with the ActiveDirectory PowerShell module (part of RSAT) and as an account with rights to disable and move user objects in the OUs you target — typically Domain Admins, or a user with delegated control over those OUs. Open PowerShell as Administrator if your execution policy or module load requires it.
This modifies real accounts. Disable-ADAccount locks people out. Move-ADObject changes an object's DN, which can change GPO and delegation that apply to it. Stamping the description overwrites any existing description. None of this deletes anything, and each step is reversible (I show how at the end), but a mistake here means help-desk tickets from people who can't log in.
Test first. Read the whole script. Run the report stage only first — it just reads. Then run the action stage against one test user in a test OU with -WhatIf before you ever point it at your whole directory. Do not paste-and-run this against a live domain blind.
One honest caveat about "inactive." AD's lastLogonTimestamp attribute (surfaced as LastLogonDate) is deliberately not updated on every logon and replicates lazily — it can lag real activity by up to ~14 days by design. Use a threshold well beyond that (90+ days) and treat borderline results as "review manually," not gospel. Service accounts and accounts that only authenticate to things that don't touch a DC may look falsely idle.
Assumptions
- Windows Server 2019 or 2022, domain-joined, single domain.
- Windows PowerShell 5.1 with the ActiveDirectory module available (
Import-Module ActiveDirectory). - You have an OU created beforehand to hold archived accounts — for example
OU=Disabled Users,DC=example,DC=com. Create it in ADUC or withNew-ADOrganizationalUnitfirst; the script does not create it. - Replace every
example.com/OU=...,DC=example,DC=complaceholder with your own values.
Stage 1 — Report only (read-nothing-changes)
Always start here. This finds candidates and writes them to a CSV you can eyeball, hand to a manager, or diff against your list of known-good service accounts.
Import-Module ActiveDirectory
# --- settings you edit ---
$InactiveDays = 90 # threshold in days
$SearchBase = "OU=Staff,DC=example,DC=com" # where to look for stale users
$ReportPath = "C:\path\to\inactive_users.csv" # where the report goes
# -------------------------
# Search-ADAccount finds enabled user accounts idle longer than the timespan.
# -UsersOnly limits to user objects; -AccountInactive uses lastLogonTimestamp/LastLogonDate.
$span = New-TimeSpan -Days $InactiveDays
$candidates = Search-ADAccount -UsersOnly -AccountInactive -TimeSpan $span -SearchBase $SearchBase |
Where-Object { $_.Enabled -eq $true } | # skip already-disabled accounts
Get-ADUser -Properties LastLogonDate, Description, whenCreated
$candidates |
Select-Object SamAccountName, Name, LastLogonDate, whenCreated, Description, DistinguishedName |
Sort-Object LastLogonDate |
Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
Write-Host "Found $($candidates.Count) candidate(s). Review: $ReportPath"
Open that CSV and remove anyone you know shouldn't be touched — service accounts, break-glass accounts, seasonal staff. What survives your review is what you act on.
Stage 2 — Disable, stamp, and archive
This stage does the changes. It is built to be safe by default: it reads back the CSV you just curated, and every mutating cmdlet supports -WhatIf. Run it with -WhatIf first to see exactly what would happen, then remove -WhatIf for the real run.
Import-Module ActiveDirectory
# --- settings you edit ---
$ReportPath = "C:\path\to\inactive_users.csv" # the curated CSV from Stage 1
$ArchiveOU = "OU=Disabled Users,DC=example,DC=com" # must already exist
$stamp = (Get-Date -Format 'yyyy-MM-dd')
# -------------------------
# Set this to $true only when you are ready to make changes for real.
$Execute = $false
$users = Import-Csv -Path $ReportPath
foreach ($u in $users) {
$sam = $u.SamAccountName
$acct = Get-ADUser -Identity $sam -Properties Description
# Preserve any existing description by prepending our note (Set-ADUser -Description overwrites).
$note = "Disabled (inactive $stamp). Was: $($acct.Description)"
if ($Execute) {
Set-ADUser -Identity $sam -Description $note
Disable-ADAccount -Identity $sam
Move-ADObject -Identity $acct.DistinguishedName -TargetPath $ArchiveOU
Write-Host "Processed $sam"
}
else {
# Dry run: show intent without changing anything.
Set-ADUser -Identity $sam -Description $note -WhatIf
Disable-ADAccount -Identity $sam -WhatIf
Move-ADObject -Identity $acct.DistinguishedName -TargetPath $ArchiveOU -WhatIf
}
}
Leave $Execute = $false for the first pass and read every What if: line. When it looks right, set $Execute = $true and run again.
A few notes on the choices here:
- I disable before moving so the object is inert during the move.
- I stamp the description so months from now you know why an account is sitting disabled in that OU, and when. Because
Set-ADUser -Descriptionreplaces the field, I fold the old value into the note rather than lose it. If you'd rather keep the original description untouched, write the note to a different attribute instead — check Microsoft Learn forSet-ADUserand the-Replaceparameter for a spare attribute likeinfo. Move-ADObjectis the standard cmdlet for relocating an object between OUs. See Microsoft Learn forMove-ADObject,Disable-ADAccount, andSearch-ADAccountfor the exact parameter reference.
Verify it worked
Confirm the accounts are disabled and now live in the archive OU:
# Everything in the archive OU should now be disabled.
Get-ADUser -Filter * -SearchBase "OU=Disabled Users,DC=example,DC=com" -Properties Enabled, Description |
Select-Object SamAccountName, Enabled, Description |
Sort-Object SamAccountName
Enabled should read False for each, the Description should carry your dated note, and every object should be present in the OU. Spot-check one account in ADUC as well — belt and braces.
Undo / roll back
Every step here is reversible. To restore a single account you disabled by mistake, re-enable it and move it back to its original OU (you kept the original DistinguishedName in the Stage 1 CSV):
$sam = "jdoe" # the account to restore
$originalOU = "OU=Staff,DC=example,DC=com" # where it lived before
Enable-ADAccount -Identity $sam
Move-ADObject -Identity (Get-ADUser $sam).DistinguishedName -TargetPath $originalOU
# The description note is text only — clear or reset it by hand if you want:
# Set-ADUser -Identity $sam -Description "your original text"
The description stamp is the one thing not auto-restored, which is why the script folds the old value into the note — you can copy it back from there or from your Stage 1 CSV.
Making it routine
Once you trust it, schedule Stage 1 (report only) to email you a monthly list, and keep Stage 2 a manual, reviewed step. Bulk-disabling accounts is exactly the kind of action you don't want a scheduled task doing unattended — a human should always look at the list first. If you later want to delete long-archived accounts, do that as a separate, deliberately gated procedure with its own backup (an AD Recycle Bin export or a full system-state backup), never bolted onto this one.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
