THE PROBLEM
Password expiry is the most predictable helpdesk ticket in existence. You know exactly when every password will expire — down to the second — and yet users still call on Monday morning locked out of everything because they ignored a Windows popup they clicked past three times.
Remote users are worse. They're not on the domain when the warning fires, so they never see it at all. Then their VPN stops authenticating, their Outlook prompts endlessly, and you spend twenty minutes on a call resetting a password that could have been changed by the user last Tuesday.
This week we flip it around. Instead of hoping users notice a popup, we email them directly at 14, 7, 3, and 1 days out — and email you a summary of who's about to expire.
THE SCRIPT
Save the script below to:
C:\Scripts\PasswordExpiryNotify.ps1PowerShell
# Password Expiry Notification System
# Automate & Operate — automateandoperate.com
Import-Module ActiveDirectory
# Email settings — update these
$from = "[email protected]"
$adminTo = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)
$logFile = "C:\Logs\PasswordExpiryNotify.log"
$reportFile = "C:\Logs\PasswordExpiryReport.csv"
# Days remaining that trigger a user email
$notifyDays = @(14, 7, 3, 1)
# Set to $true for a dry run — logs and reports but sends NO user emails
$testMode = $true
# Your company name for the user email
$companyName = "Your Company"
# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
New-Item -ItemType Directory -Path "C:\Logs"
}
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$report = @()
$notifiedCount = 0
$skippedNoEmail = @()
# Get the domain default max password age
$maxPasswordAge = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge.Days
if ($maxPasswordAge -eq 0) {
Add-Content -Path $logFile -Value "$timestamp — ABORTED: Domain policy has no password expiry set"
return
}
Add-Content -Path $logFile -Value "$timestamp — START — Max password age: $maxPasswordAge days — TestMode: $testMode"
# Get all enabled users whose passwords actually expire
$users = Get-ADUser -Filter {
Enabled -eq $true -and PasswordNeverExpires -eq $false
} -Properties PasswordLastSet, EmailAddress, DisplayName, PasswordExpired
foreach ($user in $users) {
# Skip accounts that have never had a password set
if ($null -eq $user.PasswordLastSet) { continue }
# Check for a Fine-Grained Password Policy on this user
$fgpp = Get-ADUserResultantPasswordPolicy -Identity $user -ErrorAction SilentlyContinue
$effectiveMaxAge = if ($fgpp) { $fgpp.MaxPasswordAge.Days } else { $maxPasswordAge }
if ($effectiveMaxAge -eq 0) { continue }
$expiryDate = $user.PasswordLastSet.AddDays($effectiveMaxAge)
$daysRemaining = (New-TimeSpan -Start (Get-Date) -End $expiryDate).Days
# Only care about users expiring within the largest notify window
if ($daysRemaining -lt 0 -or $daysRemaining -gt ($notifyDays | Measure-Object -Maximum).Maximum) {
continue
}
$shouldNotify = $notifyDays -contains $daysRemaining
$emailSent = "NO"
if ($shouldNotify) {
if ([string]::IsNullOrWhiteSpace($user.EmailAddress)) {
$skippedNoEmail += $user.SamAccountName
$emailSent = "NO EMAIL ON FILE"
}
elseif ($testMode) {
$emailSent = "TEST MODE — NOT SENT"
}
else {
$dayWord = if ($daysRemaining -eq 1) { "day" } else { "days" }
$userBody = @"
Hello $($user.DisplayName),
Your $companyName network password will expire in $daysRemaining $dayWord on $($expiryDate.ToString("dddd, MMMM d")).
TO CHANGE IT NOW:
If you are in the office or connected to VPN:
1. Press Ctrl + Alt + Delete
2. Click "Change a password"
3. Enter your current password, then your new one twice
If you cannot connect to the network:
Contact the service desk before your password expires.
WHAT HAPPENS IF YOU DO NOTHING:
You will be locked out of email, VPN, and all network resources until the
service desk resets your password for you.
This is an automated message. Please do not reply.
$companyName IT
"@
try {
Send-MailMessage `
-From $from -To $user.EmailAddress `
-Subject "Action required: your password expires in $daysRemaining $dayWord" `
-Body $userBody `
-SmtpServer $smtpServer -Port $smtpPort `
-UseSsl -Credential $credential
$emailSent = "SENT"
$notifiedCount++
} catch {
$emailSent = "FAILED: $_"
}
}
}
$report += [PSCustomObject]@{
DisplayName = $user.DisplayName
SamAccountName = $user.SamAccountName
EmailAddress = $user.EmailAddress
ExpiryDate = $expiryDate.ToString("yyyy-MM-dd")
DaysRemaining = $daysRemaining
Notified = $emailSent
}
Add-Content -Path $logFile -Value "$timestamp — $($user.SamAccountName) — expires $($expiryDate.ToString('yyyy-MM-dd')) — $daysRemaining days — $emailSent"
}
# Export report
$report | Sort-Object DaysRemaining | Export-Csv -Path $reportFile -NoTypeInformation
# Build admin summary
$adminBody = "Password Expiry Report — $timestamp`n"
$adminBody += "Domain max password age: $maxPasswordAge days`n"
$adminBody += "Test mode: $testMode`n"
$adminBody += "Users expiring within $(($notifyDays | Measure-Object -Maximum).Maximum) days: $($report.Count)`n"
$adminBody += "Notification emails sent: $notifiedCount`n`n"
if ($report.Count -gt 0) {
$adminBody += "UPCOMING EXPIRIES:`n"
$adminBody += "=" * 60 + "`n"
foreach ($entry in ($report | Sort-Object DaysRemaining)) {
$adminBody += "$($entry.DaysRemaining) day(s) — $($entry.DisplayName) ($($entry.SamAccountName)) — $($entry.Notified)`n"
}
}
if ($skippedNoEmail.Count -gt 0) {
$adminBody += "`nNO EMAIL ADDRESS ON FILE — CANNOT NOTIFY:`n"
$adminBody += "=" * 60 + "`n"
$skippedNoEmail | ForEach-Object { $adminBody += " !! $_`n" }
}
$adminBody += "`nFull report saved to: $reportFile`n"
$adminBody += "`nAutomate & Operate — automateandoperate.com"
Send-MailMessage `
-From $from -To $adminTo `
-Subject "Password Expiry Report — $($report.Count) upcoming, $notifiedCount notified" `
-Body $adminBody `
-SmtpServer $smtpServer -Port $smtpPort `
-UseSsl -Credential $credentialUpdate these lines:
$from— the address users will see, use a real helpdesk mailbox not a personal one$adminTo— where your daily summary goes$smtpServer— your SMTP server (e.g.smtp.office365.com)$companyName— appears in the user-facing email$notifyDays— adjust the reminder schedule if you want
Requirements: Run on a domain controller, or a machine with RSAT and the ActiveDirectory module.
START IN TEST MODE — SERIOUSLY
The script ships with $testMode = $true for a reason. This is the first script in this newsletter that emails other people. Get it wrong and you send a confusing security-flavoured email to your entire company at 6am.
Run it in test mode for two or three days first. Open C:\Logs\PasswordExpiryReport.csv and check:
Are the expiry dates plausible? Compare a couple against
net user username /domainIs anyone in the list who shouldn't be — service accounts, shared mailboxes, test accounts?
How many users have no email address on file?
Once the report looks right for a few consecutive days, set $testMode = $false.
SETTING UP TASK SCHEDULER
Press Windows key → type Task Scheduler → open it
Click "Create Basic Task" → name it
Password Expiry NotificationsTrigger: Daily → 7:00am
Action: Start a program
Program:
powershell.exeArguments:
-ExecutionPolicy Bypass -File "C:\Scripts\PasswordExpiryNotify.ps1"Click Next → Finish
Under Properties → General, check "Run with highest privileges" and set it to run under an account with AD read permissions.
Run this once daily and only once. The script notifies on exact day matches — 14, 7, 3, 1 — so running it twice a day sends duplicate emails and running it every few hours makes users hate you.
TEST IT
Open PowerShell as administrator on a domain controller and run:
powershell
powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\PasswordExpiryNotify.ps1"You'll get the admin summary email immediately even in test mode, so you can verify SMTP works before any user ever receives anything.
To test the user-facing email safely, temporarily change the send line to use your own address instead of $user.EmailAddress and set $testMode = $false. Every notification lands in your inbox instead of theirs.
A NOTE ON SERVICE ACCOUNTS
The filter excludes PasswordNeverExpires accounts, which catches most service accounts. But if you have service accounts with expiring passwords — and many environments do — they'll show up in the report with no email address and get flagged in the "cannot notify" section.
That section is worth reading carefully. A service account password expiring at 2am is exactly the kind of outage that takes three hours to diagnose because nobody thinks to check password age.
WHY THIS MATTERS
Password reset tickets are pure overhead. They generate no value, they always arrive at the worst time, and every single one was preventable with seven days' notice. Teams that automate this typically see reset tickets drop by half within a month.
The second benefit is quieter but bigger: the report shows you which accounts have no email address, which service accounts are about to expire, and which users have been dodging password changes. That's visibility you didn't have yesterday.
THIS WEEK'S ACTION
Deploy it in test mode today and let it run until Friday. Read the CSV each morning. By the end of the week you'll know your data is clean, and you can flip test mode off going into next week.
Reply to this email if you hit any issues — I read every reply.
Automate & Operate — automateandoperate.com
Write docs 4x faster. Without hating every second.
Nobody became a developer to write documentation. But the docs still need to get written — PRDs, README updates, architecture decisions, onboarding guides.
Wispr Flow lets you talk through it instead. Speak naturally about what the code does, how it works, and why you built it that way. Flow formats everything into clean, professional text you can paste into Notion, Confluence, or GitHub.
Used by engineering teams at OpenAI, Vercel, and Clay. 89% of messages sent with zero edits. Works system-wide on Mac, Windows, and iPhone.

