This article explains what the “Password never expires” setting actually means in Active Directory, why it is risky, and how to build reliable detection and alerting with minimal noise.
Why this matters?
A password is a shared secret. Over time, shared secrets tend to leak: phishing, credential stuffing, malware, password reuse, accidental exposure, or a backup dump that gets copied somewhere it shouldn’t. When you enforce periodic rotation (or at least require resets after risk signals), you reduce the time window during which a leaked secret remains valid.
The “Password never expires” flag removes one of the few automatic controls that forces rotation. For many environments, it becomes a long-lived credential with no built-in expiry pressure, which increases:
- Blast radius: compromised credentials remain usable for months/years.
- Stealth: old service accounts often go unnoticed, making them attractive targets.
- Compliance gaps: password aging policies are bypassed for those identities.
What counts as a “violation”
In on-prem Active Directory, “Password never expires” is implemented by setting the
DONT_EXPIRE_PASSWORD bit in userAccountControl.
Operationally, treat it as a violation unless the account is:
- a managed identity alternative exists (preferred),
- a gMSA or modern credential mechanism is used (preferred for services), or
- there is a documented, time-bounded exception with compensating controls.
Common exception buckets (handle deliberately)
| Account type | Typical reality | Safer direction |
|---|---|---|
| Service account (legacy) | Set to never expire to avoid outages | Migrate to gMSA / managed secrets; restrict logon; monitor usage |
| Break-glass admin | Sometimes set to never expire for availability | Use long but rotating credentials, MFA where possible, locked-down usage and monitoring |
| Vendor / integration account | Hard-coded password in an app | Secret vault + rotation; scoped permissions; alert on interactive logon |
Detection strategies
You can detect non-expiring accounts in two broad ways. Use the one that fits your tooling maturity, but keep the same policy logic and exception handling.
Option A: Periodic inventory scan (simple, robust)
Run a scheduled query (hourly/daily) that enumerates all enabled users with non-expiring passwords. Then alert on net new findings or changes since last run. This approach is reliable even if auditing is imperfect.
Option B: Event-driven alerting (fast, but depends on auditing)
Alert immediately when the attribute changes. This requires:
- Directory Services auditing for attribute changes (or a change feed via your identity platform).
- A pipeline (SIEM/SOAR) that can reliably parse “who changed what” and route alerts.
Alert design: reduce noise, increase actionability
The most common failure mode is generating a daily list that nobody acts on. A good alert must tell an operator exactly what to do next.
What to include in the alert
- Account identifiers: sAMAccountName, UPN, display name, DN, and SID (if available).
- Where it lives: OU path (often maps to ownership).
- Risk hints: memberOf (privileged groups), lastLogonTimestamp, pwdLastSet.
- Change context: if event-driven, include “changed by” + timestamp.
- Playbook link: the remediation steps and the exception request process.
Severity model (practical)
| Severity | Condition | Expected response |
|---|---|---|
| High | Privileged user or service account with broad access is set to never expire | Same-day triage; rollback if unauthorized; open security incident if suspicious |
| Medium | Standard user newly set to never expire | Investigate owner; revert unless approved exception |
| Low | Known exception still present, due for review window | Review ticket; confirm compensating controls; renew or retire |
Implementation: PowerShell periodic scan (baseline + drift detection)
Below is a practical pattern:
- Query AD for enabled users with non-expiring passwords.
- Store a snapshot (JSON/CSV).
- Alert only on additions (or on changes you define).
Prerequisites: RSAT ActiveDirectory module on the host running this job; rights to read directory attributes; a secure location to store the snapshot.
#requires -Modules ActiveDirectory
# Alert-PasswordNeverExpires.ps1
# Periodically scan AD for accounts with "Password never expires" and alert on net-new findings.
param(
[string]$SnapshotPath = "C:\Security\Snapshots\pne_snapshot.json",
[string]$ReportPath = "C:\Security\Reports\pne_new_findings.csv",
[string]$SmtpServer = "smtp.yourdomain.local",
[string]$MailFrom = "ad-alerts@yourdomain.local",
[string]$MailTo = "secops@yourdomain.local",
[string]$MailSubject = "[AD] New 'Password never expires' accounts detected"
)
# 1) Pull current state
$now = Get-Date
$users = Get-ADUser -LDAPFilter "(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(userAccountControl:1.2.840.113556.1.4.803:=65536))" `
-Properties DisplayName,UserPrincipalName,Enabled,DistinguishedName,MemberOf,LastLogonTimestamp,pwdLastSet,whenCreated,whenChanged |
Select-Object @{
Name="SamAccountName"; Expression={$_.SamAccountName}
}, @{
Name="UPN"; Expression={$_.UserPrincipalName}
}, DisplayName, Enabled, DistinguishedName, whenCreated, whenChanged, pwdLastSet,
@{
Name="LastLogonApprox"; Expression={
if ($_.LastLogonTimestamp) { [DateTime]::FromFileTime($_.LastLogonTimestamp) } else { $null }
}
},
@{
Name="IsPrivileged"; Expression={
# simple heuristic: membership contains common admin groups; tailor to your env
($_.MemberOf -match "Domain Admins|Enterprise Admins|Administrators|Account Operators") -as [bool]
}
}
# 2) Load previous snapshot
$prev = @()
if (Test-Path $SnapshotPath) {
try { $prev = Get-Content $SnapshotPath -Raw | ConvertFrom-Json } catch { $prev = @() }
}
# 3) Diff: new accounts in current that were not in previous
$prevSet = @{}
foreach ($p in $prev) { $prevSet[$p.SamAccountName] = $true }
$new = $users | Where-Object { -not $prevSet.ContainsKey($_.SamAccountName) }
# 4) Save current snapshot (always)
$dir = Split-Path $SnapshotPath -Parent
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$users | ConvertTo-Json -Depth 4 | Set-Content -Path $SnapshotPath -Encoding UTF8
# 5) If new findings, export and email
if ($new.Count -gt 0) {
$rdir = Split-Path $ReportPath -Parent
if (-not (Test-Path $rdir)) { New-Item -ItemType Directory -Path $rdir -Force | Out-Null }
$new | Export-Csv -NoTypeInformation -Path $ReportPath -Encoding UTF8
$body = @"
New 'Password never expires' accounts detected: $($new.Count)
Review actions:
1) Confirm legitimacy (change request / owner).
2) If unauthorized: revert flag immediately and investigate.
3) If exception required: document justification, compensating controls, and review date.
CSV report: $ReportPath
Run time: $now
"@
Send-MailMessage -SmtpServer $SmtpServer -From $MailFrom -To $MailTo -Subject $MailSubject -Body $body
}
Scheduling
- Run hourly for fast drift detection, or daily if your environment changes slowly.
- Use Windows Task Scheduler under a service identity with least privilege.
- Protect the snapshot directory (integrity matters; treat as security telemetry).
Implementation: SIEM/SOAR patterns
If you already centralize identity data in a SIEM, use it. Typical approaches:
- Directory inventory ingestion: ship a daily LDAP export into the SIEM; alert on deltas.
- Change events: alert on attribute change events for
userAccountControl(or the equivalent in your platform). - Policy correlation: escalate severity if the account is privileged, recently used, or has interactive logons.
- New violations (immediate action)
- Exception reviews due (governance cadence)
Remediation playbook (what your alert should drive)
- Verify intent: was there an approved change request?
- Assess account type: human user, service, integration, break-glass.
- Check privilege: group memberships; delegated rights; local admin usage.
- Decide:
- Revert “never expires” and enforce policy, or
- Approve exception with compensating controls and an expiry/review date.
- Harden if exception: long random secret, vault storage, restricted logon, monitored usage, no interactive logon, and ideally migrate to gMSA/managed auth.
- Document: owner, reason, controls, review date, and a rollback plan.
Hardening and prevention
Alerting is the safety net. Prevention reduces how often you need it.
- Prefer non-password identities for services: gMSA or platform-managed identities where available.
- Restrict who can set the flag: minimize delegated rights to modify sensitive attributes.
- Use tiering: separate admin/service identities from user OUs and apply tighter controls.
- Compensating controls for exceptions: vault + rotation process, limited logon, strong monitoring.
Metrics to track
- Count of non-expiring accounts (target: trending down).
- New violations per week (target: near zero; indicates process gaps if high).
- Mean time to remediate (target: hours/days, not weeks).
- Exception review compliance (target: 100% within window).


