Emergency server help: get in touch

AD Password Expiry Email: Reliable PowerShell Reminder Script in 6 Steps

Send HTML reminders before Active Directory passwords expire: read msDS-UserPasswordExpiryTimeComputed so fine-grained policies are respected, send through Microsoft Graph with a scoped app or through an SMTP relay with MailKit, log every message and run it daily as a gMSA scheduled task with a test mode.

Published Updated 12 min read

An AD password expiry email warns users a few days before their Active Directory password expires, so remote and cloud-first staff change it in time instead of calling the service desk after they are locked out of VPN, Wi-Fi or Outlook. This guide builds a PowerShell script that reads the exact expiry date from AD, respects fine-grained password policies, sends a clean HTML message through Microsoft Graph (or an SMTP relay), logs every result and runs daily under a group Managed Service Account.

Short answer: Query enabled users with Get-ADUser -Properties 'msDS-UserPasswordExpiryTimeComputed', mail, convert the value with [datetime]::FromFileTime(), and e-mail users whose password expires in 14, 7, 3 or 1 days. Do not use Send-MailMessage, which Microsoft marks as obsolete; send with Send-MgUserMail from an app restricted to one sender mailbox, and schedule the script as a gMSA task.

Which method to use

MethodReachesProsCons
Group Policy “Interactive logon: Prompt user to change password before expiration”Users signing in to domain PCs on the networkBuilt in; no scriptMissed by remote, Mac and cloud-only users
Script + Microsoft Graph Send-MgUserMailEveryone with a mailboxModern auth; certificate; scoped to one mailboxApp registration and Exchange Online needed
Script + SMTP relay (MailKit)Everyone with a mailboxWorks with on-premises Exchange or any relayExtra DLLs to maintain
Script + Send-MailMessageEveryoneShort codeObsolete: Microsoft says it does not guarantee secure connections

Keep the built-in logon prompt (Computer Configuration » Policies » Windows Settings » Security Settings » Local Policies » Security Options) as a second layer, and use the AD password expiry email for everyone else.

How the expiry date is calculated

Do not calculate PasswordLastSet + MaxPasswordAge yourself: that ignores fine-grained password policies (PSOs). Read the constructed attribute msDS-UserPasswordExpiryTimeComputed instead. The DC calculates it for each user as pwdLastSet plus the maximum password age of the effective policy, so a user covered by a 60-day PSO gets a 60-day date and everyone else gets the domain policy date. According to the protocol documentation it returns:

  • 0x7FFFFFFFFFFFFFFF ([int64]::MaxValue) when the password never expires, a smart card is required, or the effective maximum age is unlimited;
  • 0 when pwdLastSet is 0, meaning “must change password at next logon”;
  • otherwise a FILETIME value that [datetime]::FromFileTime() converts to local time.

Because the attribute is constructed, you must request it explicitly with -Properties and cannot filter on it in -Filter. To see which policy applies to a user, run Get-ADUserResultantPasswordPolicy -Identity jdoe; an empty result means the domain policy.

Prerequisites

  • A member server (not a DC) with the ActiveDirectory module: Install-WindowsFeature RSAT-AD-PowerShell.
  • The Graph modules installed for all users, so the gMSA can load them: Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Users.Actions -Scope AllUsers.
  • An Exchange Online sender mailbox such as it-noreply@contoso.com.
  • Rights to create an app registration in Microsoft Entra ID and to run Exchange Online PowerShell.
  • A KDS root key in the domain for gMSAs (Get-KdsRootKey returns one).
  • The mail attribute populated for users; without it, nobody can be notified.

Step 1: Register an app and restrict it to one mailbox

  1. On the server that will run the task, create a certificate whose private key cannot be exported:
    $cert = New-SelfSignedCertificate -Subject 'CN=PwdExpiryMailer' -CertStoreLocation Cert:\LocalMachine\My -KeyExportPolicy NonExportable -KeySpec Signature -KeyLength 2048 -NotAfter (Get-Date).AddYears(2)
    Export-Certificate -Cert $cert -FilePath C:\Temp\PwdExpiryMailer.cer
    $cert.Thumbprint
  2. In the Microsoft Entra admin center, go to App registrations » New registration, name it AD password expiry mailer, and upload the .cer file under Certificates & secrets. Note the Application (client) ID and the Directory (tenant) ID, and the Object ID of the matching entry under Enterprise applications.
  3. Do not grant the Graph Mail.Send application permission in Entra ID: with admin consent, it lets the app send as any mailbox in the tenant. Grant it through Exchange Online RBAC for Applications, scoped to the sender mailbox:
    Connect-ExchangeOnline
    $appId = '11111111-2222-3333-4444-555555555555'
    $spObjectId = '66666666-7777-8888-9999-000000000000'
    New-ServicePrincipal -AppId $appId -ObjectId $spObjectId -DisplayName 'AD password expiry mailer'
    New-ManagementScope -Name 'PwdExpiry sender' -RecipientRestrictionFilter "PrimarySmtpAddress -eq 'it-noreply@contoso.com'"
    New-ManagementRoleAssignment -App $appId -Role 'Application Mail.Send' -CustomResourceScope 'PwdExpiry sender'

Microsoft documents that RBAC for Applications replaces Application Access Policies and that its permissions are added to any Entra ID grants, so an unscoped Mail.Send consent in Entra would still allow sending from every mailbox. Keep the Entra permission list for this app empty.

Step 2: Create the gMSA and grant it the certificate

# On a DC or admin workstation (TASK01 is the server that runs the task)
New-ADServiceAccount -Name gmsa-pwdmail -DNSHostName gmsa-pwdmail.contoso.com -PrincipalsAllowedToRetrieveManagedPassword 'TASK01$'
# On TASK01
Install-ADServiceAccount -Identity gmsa-pwdmail
Test-ADServiceAccount -Identity gmsa-pwdmail

Test-ADServiceAccount must return True. Then:

  • Grant CONTOSO\gmsa-pwdmail$ the Log on as a batch job right on TASK01 (local policy or a GPO).
  • Open certlm.msc, find the PwdExpiryMailer certificate under Personal » Certificates, choose All Tasks » Manage Private Keys and give the gMSA Read.
  • Give the gMSA modify rights on C:\Scripts\Logs. Reading user attributes needs no extra rights with default AD permissions.

Step 3: The script

Save as C:\Scripts\Send-PasswordExpiryMail.ps1. The HTML template uses inline styles only, because curly braces in CSS would break the -f format operator.

[CmdletBinding()]
param(
    [int[]]$NotifyDays     = @(14, 7, 3, 1),
    [string]$SearchBase    = 'OU=Staff,DC=contoso,DC=com',
    [string]$Sender        = 'it-noreply@contoso.com',
    [string]$TenantId      = 'contoso.onmicrosoft.com',
    [string]$ClientId      = '11111111-2222-3333-4444-555555555555',
    [string]$CertThumbprint = 'REPLACE-WITH-THUMBPRINT',
    [string]$LogPath       = 'C:\Scripts\Logs\pwd-expiry-mail.csv',
    [switch]$ReportOnly,
    [switch]$TestMode,
    [string]$TestRecipient = 'it-admin@contoso.com'
)
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory
$template = @'
<html><body style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222222">
<p>Hello {0},</p>
<p>Your Contoso network password expires in <strong>{1} day(s)</strong>, on <strong>{2}</strong>.</p>
<p>To change it on a company PC, press Ctrl+Alt+Del and choose <strong>Change a password</strong>.
If you work remotely, connect to the VPN first so your laptop learns the new password.</p>
<p>After the change, sign in again on your phone and other devices that use this account.</p>
<p>IT Service Desk - this is an automated message, please do not reply.</p>
</body></html>
'@
if (-not $ReportOnly) {
    Import-Module Microsoft.Graph.Authentication, Microsoft.Graph.Users.Actions
    $cert = Get-Item -Path "Cert:\LocalMachine\My\$CertThumbprint"
    Connect-MgGraph -ClientId $ClientId -TenantId $TenantId -Certificate $cert -NoWelcome
}
$today = (Get-Date).Date
$users = Get-ADUser -SearchBase $SearchBase -Filter 'Enabled -eq $true -and PasswordNeverExpires -eq $false' -Properties mail, GivenName, 'msDS-UserPasswordExpiryTimeComputed'
foreach ($u in $users) {
    $raw = $u.'msDS-UserPasswordExpiryTimeComputed'
    if (-not $raw -or $raw -eq [int64]::MaxValue) { continue }
    $expires = [datetime]::FromFileTime($raw)
    $days = ($expires.Date - $today).Days
    if ($NotifyDays -notcontains $days) { continue }
    $to = if ($TestMode) { $TestRecipient } else { $u.mail }
    $status = 'Sent'
    if (-not $u.mail) { $status = 'NoMailAttribute' }
    elseif ($ReportOnly) { $status = 'ReportOnly' }
    else {
        $name = [System.Net.WebUtility]::HtmlEncode($(if ($u.GivenName) { $u.GivenName } else { $u.Name }))
        $html = $template -f $name, $days, $expires.ToString('dddd d MMMM yyyy, HH:mm')
        $message = @{
            message = @{
                subject      = "Your password expires in $days day(s)"
                body         = @{ contentType = 'HTML'; content = $html }
                toRecipients = @(@{ emailAddress = @{ address = $to } })
            }
            saveToSentItems = $false
        }
        try { Send-MgUserMail -UserId $Sender -BodyParameter $message }
        catch { $status = "Failed: $($_.Exception.Message)" }
    }
    [pscustomobject]@{
        Run = (Get-Date -Format s); User = $u.SamAccountName; To = $to
        Expires = $expires.ToString('s'); DaysLeft = $days; Status = $status; TestMode = [bool]$TestMode
    } | Export-Csv -Path $LogPath -Append -NoTypeInformation -Encoding UTF8
}
if (-not $ReportOnly) { Disconnect-MgGraph | Out-Null }

How the script works

  • Filtering: the AD query returns only enabled users whose password can expire; the loop skips the 0 and never-expires values described above.
  • Timing: a user gets one AD password expiry email on each day listed in $NotifyDays. Change the list to suit your policy, for example @(10, 5, 2, 1).
  • Encoding: the first name is HTML-encoded, so names such as “O’Brien” or accented characters display correctly and cannot inject markup.
  • Logging: every notified user produces one CSV line with the result, including Graph errors, so you can prove who was warned and when.
  • Modes: -ReportOnly sends nothing and needs no Graph connection; -TestMode sends every message to $TestRecipient instead of the user.

Add a daily summary for IT

Users without a mail value and passwords that already expired need a person to follow up. Append this to the script to list them in the console output or the task transcript:

$expired = $users | Where-Object {
    $v = $_.'msDS-UserPasswordExpiryTimeComputed'
    $v -gt 0 -and $v -lt [int64]::MaxValue -and [datetime]::FromFileTime($v) -lt (Get-Date)
}
$noMail = $users | Where-Object { -not $_.mail }
"Expired: $($expired.Count)  No mail attribute: $($noMail.Count)"
$expired | Select-Object SamAccountName, Name | Format-Table -AutoSize

Send the same numbers to the service desk mailbox with the Graph block above if you want them in an inbox rather than a log.

Step 4 (alternative): Send through an SMTP relay

Without Exchange Online, use an authenticated or IP-restricted SMTP relay with MailKit, the library Microsoft’s Send-MailMessage documentation points to. Download the MimeKit and MailKit NuGet packages (and their dependencies) for your PowerShell version, place the DLLs in C:\Scripts\lib, and replace the Graph block with:

Add-Type -Path 'C:\Scripts\lib\MimeKit.dll'
Add-Type -Path 'C:\Scripts\lib\MailKit.dll'
$mail = [MimeKit.MimeMessage]::new()
$mail.From.Add([MimeKit.MailboxAddress]::new('IT Service Desk', 'it-noreply@contoso.com'))
$mail.To.Add([MimeKit.MailboxAddress]::new($u.Name, $to))
$mail.Subject = "Your password expires in $days day(s)"
$builder = [MimeKit.BodyBuilder]::new()
$builder.HtmlBody = $html
$mail.Body = $builder.ToMessageBody()
$smtp = [MailKit.Net.Smtp.SmtpClient]::new()
$smtp.Connect('relay.contoso.com', 587, [MailKit.Security.SecureSocketOptions]::StartTls)
$smtp.Send($mail)
$smtp.Disconnect($true)

PowerShell 7 loads current MailKit builds with fewer dependency problems than Windows PowerShell 5.1. Keep TLS enforced on the relay connection; that is the gap that made Send-MailMessage obsolete.

Step 5: Schedule the script as the gMSA

$action    = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Send-PasswordExpiryMail.ps1'
$trigger   = New-ScheduledTaskTrigger -Daily -At 7:45am
$principal = New-ScheduledTaskPrincipal -UserId 'CONTOSO\gmsa-pwdmail$' -LogonType Password
$settings  = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1) -StartWhenAvailable
Register-ScheduledTask -TaskName 'AD password expiry email' -Action $action -Trigger $trigger -Principal $principal -Settings $settings

-LogonType Password with a name ending in $ tells Task Scheduler to fetch the gMSA password from AD, so there is nothing to store or rotate. If you must use a normal service account instead, give it a long random password, Log on as a batch job, and no interactive logon rights, and record who owns the password.

Step 6: Test before going live

  1. Run interactively with .\Send-PasswordExpiryMail.ps1 -ReportOnly and open the CSV: check the users, days left and dates against a few accounts in ADUC.
  2. Run with -TestMode to receive every message yourself. Check the layout in Outlook desktop, Outlook on the web and a phone.
  3. Set a test user’s password to expire soon, for example with a test PSO that has a short maximum age, and confirm the reminder arrives.
  4. Start the task once by hand: Start-ScheduledTask -TaskName 'AD password expiry email', then Get-ScheduledTaskInfo -TaskName 'AD password expiry email'. LastTaskResult must be 0.
  5. Remove -TestMode (it is off by default) and let the daily run take over. Review the log weekly for Failed and NoMailAttribute lines for the first month.

Fine-grained password policies

No change is needed in the script: msDS-UserPasswordExpiryTimeComputed already uses each user’s effective policy. To review which PSOs exist and who they apply to:

Get-ADFineGrainedPasswordPolicy -Filter * | Select-Object Name, Precedence, MaxPasswordAge, AppliesTo
Get-ADDefaultDomainPasswordPolicy | Select-Object MaxPasswordAge

If a PSO sets a short maximum age for administrators, consider a separate reminder list with an earlier first warning; privileged accounts should not expire unnoticed either.

Troubleshooting

SymptomCauseFix
Graph returns 403 / ErrorAccessDeniedNo scoped role assignment, or the sender is outside the management scopeCheck Get-ManagementRoleAssignment -Role 'Application Mail.Send' and the scope filter; allow time for permission changes to apply
“Keyset does not exist” or certificate not foundgMSA cannot read the private key, or wrong thumbprintGrant Read in Manage Private Keys; check the thumbprint has no spaces
Task result not 0, no log fileModules installed per user, or no batch logon rightInstall modules with -Scope AllUsers; grant Log on as a batch job
Some users never get a reminderNo mail, outside $SearchBase, or a missed daily runCheck NoMailAttribute lines; widen the search base; keep the 1-day reminder
Dates one hour offDaylight saving between now and expiryFromFileTime returns local time; show the date only if this confuses users

Roll back or pause

To pause reminders, run Disable-ScheduledTask -TaskName 'AD password expiry email'. To remove the solution completely, unregister the task, remove the role assignment with Remove-ManagementRoleAssignment, delete the management scope and the app registration, and remove the gMSA with Remove-ADServiceAccount. The users’ passwords and policies are never changed by the script, so there is nothing else to undo. A daily AD password expiry email costs a few minutes to set up and removes one of the most common reasons remote users get locked out.

AD password expiry email at a glance

AD Password Expiry Email summary card: Query enabled users with Get-ADUser -Properties 'msDS-UserPasswordExpiryTimeComputed', mail, convert the value with…
In short: Query enabled users with Get-ADUser -Properties ‘msDS-UserPasswordExpiryTimeComputed’, mail, convert the value with [datetime]::FromFileTime(), and e-mail users whose password expires in 14, 7, 3 or 1 days.

Official documentation: ms-DS-User-Password-Expiry-Time-Computed attribute, Send-MgUserMail (Microsoft Graph PowerShell), Role Based Access Control for Applications in Exchange Online.

Related guides: Fine-Grained Password Policy (PSO) in Active Directory: Easy 2026 Setup · Group Managed Service Accounts (gMSA) · Get-ADUser PowerShell examples.

Frequently asked questions

Why not calculate expiry from PasswordLastSet and the domain policy?

That ignores fine-grained password policies. msDS-UserPasswordExpiryTimeComputed is calculated by the domain controller from each user’s effective policy, so it is always the correct date.

Can I still use Send-MailMessage?

It still runs, but Microsoft marks it obsolete because it does not guarantee secure connections to SMTP servers. Use Send-MgUserMail with Exchange Online, or MailKit with an SMTP relay.

How do I stop the app from sending as any mailbox?

Do not grant Mail.Send in Entra ID. Assign the Application Mail.Send role through Exchange Online RBAC for Applications with a management scope that contains only the sender mailbox.

Can the scheduled task run as a gMSA?

Yes. Register the task with New-ScheduledTaskPrincipal -UserId ‘DOMAIN\gmsa-name$’ -LogonType Password, and give the gMSA the Log on as a batch job right and read access to the certificate’s private key.

What does a value of 0 in msDS-UserPasswordExpiryTimeComputed mean?

The user must change the password at next logon because pwdLastSet is 0. The script skips these accounts.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.