Published on

How to Find Last Password Change Date in Active Directory (PowerShell Script)

Password age is one of the first things a security review asks for, and it's not quite as simple as reading one attribute — you also want to know whether the account even has a real password set yet, and whether it's exempt from expiration entirely, before the raw number means anything.

The quick answer

Get-ADUser -Identity jdoe -Properties PasswordLastSet | Select-Object PasswordLastSet

For everyone at once:

Get-ADUser -Filter * -Properties PasswordLastSet | Select-Object Name, PasswordLastSet

A more useful reporting script

#requires -Modules ActiveDirectory
<#
.SYNOPSIS
    Reports when each Active Directory user's password was last changed.
#>

[CmdletBinding()]
param(
    [int]$MinAgeDays,
    [string]$SearchBase,
    [string]$OutputCsv
)

Import-Module ActiveDirectory -ErrorAction Stop

$now = Get-Date

$params = @{
    Filter     = { Enabled -eq $true }
    Properties = @('PasswordLastSet', 'PasswordNeverExpires', 'DistinguishedName')
}
if ($SearchBase) { $params['SearchBase'] = $SearchBase }

$users = Get-ADUser @params

$results = foreach ($user in $users) {
    $ageDays = if ($user.PasswordLastSet) { [Math]::Round(($now - $user.PasswordLastSet).TotalDays) } else { $null }

    if ($MinAgeDays -and (-not $ageDays -or $ageDays -lt $MinAgeDays)) { continue }

    [PSCustomObject]@{
        Name                 = $user.Name
        SamAccountName       = $user.SamAccountName
        PasswordLastSet      = $user.PasswordLastSet
        PasswordAgeDays      = $ageDays
        PasswordNeverExpires = $user.PasswordNeverExpires
        Status               = if (-not $user.PasswordLastSet) { 'Never set - must change at next logon' } else { 'OK' }
        DistinguishedName    = $user.DistinguishedName
    }
}

if (-not $results) {
    Write-Host "No users found matching the given criteria." -ForegroundColor Yellow
    return
}

$sorted = $results | Sort-Object PasswordAgeDays -Descending

Write-Host "Found $(@($sorted).Count) user(s)." -ForegroundColor Cyan
$sorted | Format-Table Name, SamAccountName, PasswordLastSet, PasswordAgeDays, PasswordNeverExpires, Status -AutoSize

$neverSetCount = @($sorted | Where-Object { -not $_.PasswordLastSet }).Count
if ($neverSetCount -gt 0) {
    Write-Host "$neverSetCount account(s) have never had a password set by the user." -ForegroundColor Red
}

if ($OutputCsv) {
    $sorted | Export-Csv -Path $OutputCsv -NoTypeInformation
    Write-Host "Exported results to $OutputCsv" -ForegroundColor Green
}

Sorted descending by age by default, so the stalest passwords land at the top — exactly what you want for a "who hasn't rotated in over a year" review with -MinAgeDays 365.

Why PasswordLastSet is sometimes blank

A blank PasswordLastSet doesn't mean "unknown" — it means the password has literally never been changed by the user since the account was created or reset, and "user must change password at next logon" is still pending. Reporting this as an age of 0 or skipping it silently would both be misleading, which is why the script flags it as its own status instead.

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.

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/Get-ADPasswordLastSetReport.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.