How to set up sending automated password expiry notice to AD users.
A daily scheduled task on a domain controller (or any domain-joined server with the AD PowerShell module) emails users before their Active Directory password expires, sending through a Microsoft 365 mailbox.
Users get an email 14, 7, 3, 2 and 1 days before expiry. Disabled accounts, "Password never expires" accounts and accounts set to "User must change password at next logon" are skipped. Fine-Grained Password Policies are respected.
Step 1: Set up the Microsoft 365 sender account
- Create a user in Microsoft 365 to send from, for example
noreply@yourdomain.com, and assign it a license that includes an Exchange Online mailbox. Give it a long, unique password. - Enable SMTP AUTH for that mailbox in Exchange Online PowerShell:
# If the module is missing: Install-Module ExchangeOnlineManagement
Connect-ExchangeOnline
Set-CASMailbox -Identity noreply@yourdomain.com -SmtpClientAuthenticationDisabled $false - Make sure nothing blocks it: Security Defaults must be off, any Conditional Access policy blocking legacy authentication must exclude this account, and the account cannot have MFA.
Step 2: Install the script
On the server, create the folder and lock it to SYSTEM and Administrators:
New-Item -ItemType Directory -Path C:\Scripts\PwExpiry -Force | Out-Null
icacls C:\Scripts\PwExpiry /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F"
Save the script below as C:\Scripts\PwExpiry\Send-PasswordExpiryNotice.ps1, then edit the CONFIG block: From, FromDisplayName, SearchBase (empty = whole domain), NotifyDays, AdminRecipients (optional daily summary), CompanyName and HelpContact.
<#
.SYNOPSIS
Emails AD users before their password expires, through a Microsoft 365 mailbox (SMTP AUTH).
One-time setup (elevated): .\Send-PasswordExpiryNotice.ps1 -SetSecret
Scheduled run: .\Send-PasswordExpiryNotice.ps1
#>
param([switch]$SetSecret)
#region ---------------- CONFIG ----------------
$Config = @{
From = 'noreply@contoso.com' # M365 sender mailbox, also the SMTP username
FromDisplayName = 'IT Support'
SmtpServer = 'smtp.office365.com'
SmtpPort = 587
SearchBase = @() # Empty = whole domain. Else @('OU=Staff,DC=contoso,DC=com')
NotifyDays = @(14, 7, 3, 2, 1) # Days before expiry on which a mail goes out
AdminRecipients = @() # Daily summary, e.g. @('it@contoso.com'). Empty = none
CompanyName = 'Contoso'
HelpContact = 'it@contoso.com'
SecretPath = Join-Path $PSScriptRoot 'pwnotify.secret'
LogPath = Join-Path $PSScriptRoot 'logs'
LogRetentionDays = 90
}
#endregion
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Security
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
if (-not (Test-Path $Config.LogPath)) { New-Item -ItemType Directory -Path $Config.LogPath | Out-Null }
$LogFile = Join-Path $Config.LogPath ('pwnotify_{0:yyyyMMdd}.log' -f (Get-Date))
function Write-Log {
param([string]$Msg, [string]$Level = 'INFO')
$line = '{0:yyyy-MM-dd HH:mm:ss} [{1}] {2}' -f (Get-Date), $Level, $Msg
Add-Content -Path $LogFile -Value $line
Write-Host $line
}
if ($SetSecret) {
$sec = Read-Host -AsSecureString "Enter SMTP password for $($Config.From)"
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
try { $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
$enc = [Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($plain), $null,
[Security.Cryptography.DataProtectionScope]::LocalMachine)
[IO.File]::WriteAllBytes($Config.SecretPath, $enc)
& icacls.exe $Config.SecretPath /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null
Write-Host "Password stored in $($Config.SecretPath) (SYSTEM + Administrators only)."
return
}
function Send-Mail {
param([string[]]$To, [string]$Subject, [string]$Html)
$mail = New-Object Net.Mail.MailMessage
$mail.From = New-Object Net.Mail.MailAddress($Config.From, $Config.FromDisplayName)
foreach ($r in $To) { $mail.To.Add($r) }
$mail.Subject = $Subject; $mail.Body = $Html; $mail.IsBodyHtml = $true
$mail.BodyEncoding = [Text.Encoding]::UTF8; $mail.SubjectEncoding = [Text.Encoding]::UTF8
$smtp = New-Object Net.Mail.SmtpClient($Config.SmtpServer, $Config.SmtpPort)
$smtp.EnableSsl = $true
$smtp.Credentials = New-Object Net.NetworkCredential($Config.From, $script:Password)
try { $smtp.Send($mail) } finally { $mail.Dispose(); $smtp.Dispose() }
}
function New-UserBody {
param($User, [datetime]$Expires, [int]$Days)
$e = { param($s) [Net.WebUtility]::HtmlEncode([string]$s) }
$name = if ($User.givenName) { $User.givenName } else { $User.displayName }
$when = if ($Days -eq 1) { 'tomorrow' } else { "in $Days days" }
@"
<html><body style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222">
<p>Hello $(& $e $name),</p>
<p>Your $(& $e $Config.CompanyName) network password (<b>$(& $e $User.SamAccountName)</b>) expires <b>$when</b>,
on <b>$(& $e $Expires.ToString('dddd, d MMMM yyyy HH:mm'))</b>.</p>
<p>To change it, press <b>Ctrl+Alt+Del</b> on a company computer, or <b>Ctrl+Alt+End</b> inside a remote desktop session, and choose <b>Change a password</b>.</p>
<p>Questions: $(& $e $Config.HelpContact)</p>
<p style="color:#888;font-size:12px">Automated message, do not reply.</p>
</body></html>
"@
}
#region ---------------- MAIN ----------------
$errors = 0
try {
Import-Module ActiveDirectory
if (-not (Test-Path $Config.SecretPath)) { throw "No stored password at $($Config.SecretPath). Run with -SetSecret first." }
$script:Password = [Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect(
[IO.File]::ReadAllBytes($Config.SecretPath), $null, [Security.Cryptography.DataProtectionScope]::LocalMachine))
$props = 'mail', 'displayName', 'givenName', 'pwdLastSet', 'msDS-UserPasswordExpiryTimeComputed'
$filter = 'Enabled -eq $true -and PasswordNeverExpires -eq $false'
$users = if ($Config.SearchBase.Count -gt 0) {
foreach ($sb in $Config.SearchBase) { Get-ADUser -Filter $filter -SearchBase $sb -Properties $props }
} else { Get-ADUser -Filter $filter -Properties $props }
$users = @($users | Sort-Object DistinguishedName -Unique)
Write-Log "Run start. Candidate accounts: $($users.Count)"
}
catch { Write-Log "Fatal: $($_.Exception.Message)" 'ERROR'; exit 2 }
$today = (Get-Date).Date
$summary = New-Object System.Collections.Generic.List[object]
foreach ($u in $users) {
$raw = $u.'msDS-UserPasswordExpiryTimeComputed'
if (-not $raw -or $raw -eq 0 -or $raw -eq [Int64]::MaxValue -or $u.pwdLastSet -eq 0) { continue }
$expires = [DateTime]::FromFileTime($raw)
$days = ($expires.Date - $today).Days
if ($days -lt 0) { $summary.Add([pscustomobject]@{ User = $u.SamAccountName; Expires = $expires; Days = $days; Result = 'Already expired' }); continue }
if ($Config.NotifyDays -notcontains $days) { continue }
if (-not $u.mail) {
Write-Log "$($u.SamAccountName): expires in $days d, no mail attribute" 'WARN'
$summary.Add([pscustomobject]@{ User = $u.SamAccountName; Expires = $expires; Days = $days; Result = 'No mail attribute' }); continue
}
$subject = if ($days -eq 1) { 'Your password expires tomorrow' } else { "Your password expires in $days days" }
try {
Send-Mail -To $u.mail -Subject $subject -Html (New-UserBody -User $u -Expires $expires -Days $days)
Write-Log "SENT: $($u.SamAccountName) -> $($u.mail) days=$days"
$summary.Add([pscustomobject]@{ User = $u.SamAccountName; Expires = $expires; Days = $days; Result = 'Sent' })
Start-Sleep -Seconds 2
}
catch {
$errors++
Write-Log "FAILED: $($u.SamAccountName) -> $($u.mail) : $($_.Exception.Message)" 'ERROR'
$summary.Add([pscustomobject]@{ User = $u.SamAccountName; Expires = $expires; Days = $days; Result = "FAILED: $($_.Exception.Message)" })
}
}
if ($Config.AdminRecipients.Count -gt 0 -and $summary.Count -gt 0) {
$rows = ($summary | Sort-Object Days | ForEach-Object {
'<tr><td>{0}</td><td>{1:yyyy-MM-dd HH:mm}</td><td>{2}</td><td>{3}</td></tr>' -f $_.User, $_.Expires, $_.Days, [Net.WebUtility]::HtmlEncode($_.Result)
}) -join "`n"
$html = "<html><body style='font-family:Segoe UI,Arial,sans-serif;font-size:13px'><p>Password expiry run on $env:COMPUTERNAME. Errors: $errors</p><table border='1' cellpadding='4' cellspacing='0' style='border-collapse:collapse'><tr><th>User</th><th>Expires</th><th>Days</th><th>Result</th></tr>$rows</table></body></html>"
try { Send-Mail -To $Config.AdminRecipients -Subject "Password expiry report: $errors errors" -Html $html }
catch { $errors++; Write-Log "Admin summary FAILED: $($_.Exception.Message)" 'ERROR' }
}
Get-ChildItem $Config.LogPath -Filter 'pwnotify_*.log' |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$Config.LogRetentionDays) } | Remove-Item -Force
Write-Log "Run end. Errors: $errors"
if ($errors -gt 0) { exit 1 } else { exit 0 }
#endregionStep 3: Store the password
In an elevated PowerShell window on the same server, run once and enter the sender mailbox password when prompted:
Unblock-File C:\Scripts\PwExpiry\Send-PasswordExpiryNotice.ps1
C:\Scripts\PwExpiry\Send-PasswordExpiryNotice.ps1 -SetSecret
The password is encrypted to pwnotify.secret and only decrypts on this server. Run -SetSecret again whenever the password changes.
Step 4: Schedule it
In the same elevated window (change 08:00 if needed):
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\PwExpiry\Send-PasswordExpiryNotice.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 08:00
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 1)
Register-ScheduledTask -TaskName 'AD Password Expiry Notifier' -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force
Run it on one server only, or users get every email twice.
Step 5: Confirm it works
Start-ScheduledTask -TaskName 'AD Password Expiry Notifier'
Get-ScheduledTaskInfo -TaskName 'AD Password Expiry Notifier' | Select-Object LastRunTime, LastTaskResult
Get-Content "C:\Scripts\PwExpiry\logs\pwnotify_$(Get-Date -Format yyyyMMdd).log"
LastTaskResult | Meaning |
|---|---|
0 | All emails sent |
1 | At least one email failed; see |
2 | Stopped before sending: AD module missing, AD query failed, or no stored password (Step 3) |
An SMTP authentication failure in the log means one of the Step 1 items is not in place.
Updated on: 15/10/2026
Thank you!
