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.
Table of Contents
Which method to use
| Method | Reaches | Pros | Cons |
|---|---|---|---|
| Group Policy “Interactive logon: Prompt user to change password before expiration” | Users signing in to domain PCs on the network | Built in; no script | Missed by remote, Mac and cloud-only users |
Script + Microsoft Graph Send-MgUserMail | Everyone with a mailbox | Modern auth; certificate; scoped to one mailbox | App registration and Exchange Online needed |
| Script + SMTP relay (MailKit) | Everyone with a mailbox | Works with on-premises Exchange or any relay | Extra DLLs to maintain |
Script + Send-MailMessage | Everyone | Short code | Obsolete: 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;0whenpwdLastSetis 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-KdsRootKeyreturns one). - The
mailattribute populated for users; without it, nobody can be notified.
Step 1: Register an app and restrict it to one mailbox
- 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 - In the Microsoft Entra admin center, go to App registrations » New registration, name it AD password expiry mailer, and upload the
.cerfile under Certificates & secrets. Note the Application (client) ID and the Directory (tenant) ID, and the Object ID of the matching entry under Enterprise applications. - Do not grant the Graph
Mail.Sendapplication 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
0and 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:
-ReportOnlysends nothing and needs no Graph connection;-TestModesends every message to$TestRecipientinstead 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
- Run interactively with
.\Send-PasswordExpiryMail.ps1 -ReportOnlyand open the CSV: check the users, days left and dates against a few accounts in ADUC. - Run with
-TestModeto receive every message yourself. Check the layout in Outlook desktop, Outlook on the web and a phone. - 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.
- Start the task once by hand:
Start-ScheduledTask -TaskName 'AD password expiry email', thenGet-ScheduledTaskInfo -TaskName 'AD password expiry email'.LastTaskResultmust be0. - 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
| Symptom | Cause | Fix |
|---|---|---|
| Graph returns 403 / ErrorAccessDenied | No scoped role assignment, or the sender is outside the management scope | Check Get-ManagementRoleAssignment -Role 'Application Mail.Send' and the scope filter; allow time for permission changes to apply |
| “Keyset does not exist” or certificate not found | gMSA cannot read the private key, or wrong thumbprint | Grant Read in Manage Private Keys; check the thumbprint has no spaces |
| Task result not 0, no log file | Modules installed per user, or no batch logon right | Install modules with -Scope AllUsers; grant Log on as a batch job |
| Some users never get a reminder | No mail, outside $SearchBase, or a missed daily run | Check NoMailAttribute lines; widen the search base; keep the 1-day reminder |
| Dates one hour off | Daylight saving between now and expiry | FromFileTime 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

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.