A single sign-on application I work on used to calculate Active Directory password expiry from pwdLastSet and the domain's default maximum password age. That worked until the organization introduced fine-grained password policies. Different accounts could now follow different expiry rules within the same domain.
Some users began receiving warnings for dates that did not apply to them. The application was doing the arithmetic for the wrong policy.
Active Directory already exposes the answer through msDS-UserPasswordExpiryTimeComputed. It is a constructed attribute: the domain controller calculates it when requested, using the account's effective password policy and relevant account settings.
I compared the old calculation with the computed attribute across 4,230 accounts in three domains. The results agreed for 4,041 accounts. Among the differences, two findings stood out:
- Of the 121 accounts governed by fine-grained policies, 45 had been assigned an expiry date that was wrong by 305 days.
- Another 123 accounts differed by exactly one hour across daylight saving transitions. The old code used local calendar arithmetic, while the domain controller worked from UTC file-time data.
That comparison was more useful than checking a couple of accounts that happened to follow the default policy. It showed both a policy problem and a time-handling problem.
Reading the attribute is straightforward, but interpreting it takes a little care. Microsoft's documented rules include special values:
Int64.MaxValuereports no finite expiry. This can come from a non-expiring password policy or account settings such as password-never-expires or smart-card-required. It does not identify the password-never-expires checkbox alone.0meanspwdLastSetis zero or absent, after the earlier account-setting checks. For an ordinary user account, zero is commonly associated with a required password change at the next logon.- A finite expiry value is a Windows file time. Convert it with
DateTime.FromFileTimeUtc, after checking the special values.
I would keep those states separate from the date itself. Using DateTime.MinValue and DateTime.MaxValue as substitutes makes it too easy for later code to treat a status as an actual date.
This read-only PowerShell example uses DirectorySearcher and explicitly requests the attribute. Replace the sample domain controller, search base, and account name. It runs on Windows under the current identity and assumes LDAPS is configured with a trusted certificate:
Add-Type -AssemblyName System.DirectoryServices
$attribute = 'msDS-UserPasswordExpiryTimeComputed'
$auth = [System.DirectoryServices.AuthenticationTypes]::Secure -bor
[System.DirectoryServices.AuthenticationTypes]::SecureSocketsLayer
$root = [System.DirectoryServices.DirectoryEntry]::new(
'LDAP://dc.example.com:636/DC=example,DC=com',
$null, $null, $auth)
$searcher = $null
try {
$searcher = [System.DirectoryServices.DirectorySearcher]::new($root)
$searcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName=jdoe))'
[void]$searcher.PropertiesToLoad.Add($attribute)
$result = $searcher.FindOne()
if ($null -eq $result) {
throw 'Account not found in the search base.'
}
$values = $result.Properties[$attribute.ToLowerInvariant()]
if ($null -eq $values -or $values.Count -ne 1) {
throw 'The domain controller did not return an expiry value.'
}
$raw = [Int64]$values[0]
$expiresUtc = $null
if ($raw -eq [Int64]::MaxValue) {
$state = 'NoFiniteExpiry'
}
elseif ($raw -eq 0) {
$state = 'NoLastSetTime'
}
else {
$expiresUtc = [DateTime]::FromFileTimeUtc($raw)
$state = 'ExpiryDate'
}
[pscustomobject]@{
State = $state
ExpiresUtc = $expiresUtc
}
}
finally {
if ($null -ne $searcher) { $searcher.Dispose() }
$root.Dispose()
}
The search targets a domain controller in the account's domain. The filter uses a fixed sample account name; application code that builds a filter from input must escape LDAP filter values. The script reports a missing account or attribute as an error, before either can accidentally become a numeric zero.
Keep the expiry in UTC when comparing it with the current time. FromFileTimeUtc returns a UTC DateTime; FromFileTime returns local time. Convert for display when needed. Neither method can turn Int64.MaxValue into a valid DateTime, which is why the special-value checks come first.
There is also a difference between the .NET access paths. DirectorySearcher returns this value as an Int64. Reading through DirectoryEntry can expose an ADSI large-integer COM object with HighPart and LowPart instead. If you use that path, explicitly load the attribute with RefreshCache and handle the returned representation. Checking Properties.Contains alone does not ensure that the constructed attribute was requested.
I would also remove the silent fallback to the domain's default maximum age. That is the calculation that produced the wrong dates in the first place. If compatibility requires retaining it temporarily, report the result as an estimate and make the fallback visible in logs or metrics. A missing directory value should not become a confident expiry date or a claim that the password never expires.
The application still needs to decide when and how to warn someone. I would rather maintain that logic than a second implementation of Active Directory's password-policy rules.