Estimated read time: ~10–12 minutes

Audience: Windows/AD administrators, M365/Entra admins, automation engineers
Overview
Password expiry is one of the most common causes of user lockouts and helpdesk calls. A small PowerShell automation that detects upcoming password expirations from Active Directory and emails users proactively can reduce disruption significantly. One modern way to send email in Microsoft 365 is to use Microsoft Graph and the sendMail endpoint, which supports JSON payloads, HTML bodies, and attachments.
In this guide, you’ll build an end‑to‑end solution that:
- Queries AD for each user’s password expiry timestamp using
msDS-UserPasswordExpiryTimeComputed(computed by the DC). - Sends reminders only on specific “warning days” (e.g., 30/14/7/3/1/0).
- Sends email through Microsoft Graph using app‑only certificate authentication (no stored passwords).
- Uses Graph
POST /users/{id|upn}/sendMailto send via a mailbox.
Architecture (How it Works)
Flow:
- PowerShell runs on a scheduled server (Task Scheduler).
- Script queries AD users and reads
msDS-UserPasswordExpiryTimeComputed. [learn.microsoft.com] - If user is within your warning intervals (e.g., 14 days left), script builds a branded HTML email.
- Script authenticates to Graph with Connect‑MgGraph app‑only cert auth.
- Script calls Graph
/sendMailto send the message.
Prerequisites
1) Active Directory access
- Script host must be able to run
Get-ADUser(RSAT AD PowerShell module installed and permissions to read users). - You will query
msDS-UserPasswordExpiryTimeComputed, which is computed by the DC based on password policy and user flags.
2) Microsoft Graph PowerShell SDK
You’ll use the Graph PowerShell SDK for authentication and requests. The connection entry point is Connect-MgGraph.
3) App registration with certificate (App‑Only)
For unattended automation, use app‑only authentication. Microsoft’s guidance for Graph PowerShell app‑only flows requires:
- an Entra app registration,
- a certificate credential,
- and admin consent for required Graph permissions.
Step‑by‑Step Setup
Step 1 — Create an Entra App Registration
Create an app registration in Microsoft Entra ID and note:
- Tenant ID
- Client (Application) ID
App‑only access requires an administrator to consent to the permissions for the application.
Step 2 — Create/Upload a Certificate Credential
Generate or use an existing X.509 certificate. Upload the public key to the app registration and install the certificate (with private key) on the server running the script. App‑only Graph PowerShell auth supports certificate thumbprint authentication.
Step 3 — Assign Microsoft Graph Permissions
At minimum, to send mail, your app needs:
- Application permission:
Mail.Send[learn.microsoft.com]
Grant admin consent after adding it (required for app‑only). [github.com], [learn.microsoft.com]
⚠️ Security note:
Mail.Send(Application) is powerful because it can send mail as mailboxes in the tenant depending on how you call/users/{id}/sendMail. Restrict it where possible. [learn.microsoft.com], [learn.microsoft.com]
Step 4 — Restrict Mailbox Scope (Recommended)
To reduce risk, restrict the app to only an allowed set of mailboxes (for example, only a dedicated automation mailbox). In Exchange Online, this can be done with Application Access Policies (legacy) which restrict app access to a scope group; Microsoft notes these policies are being replaced by RBAC for applications. [learn.microsoft.com]
How Password Expiry Is Calculated in AD
The computed attribute msDS-UserPasswordExpiryTimeComputed represents the time when a user’s password will expire. AD calculates it using rules such as:
- If “password never expires” or certain UAC flags are set → value becomes a max sentinel.
- Otherwise, expiry is essentially
pwdLastSet + Effective-MaximumPasswordAge. [learn.microsoft.com]
This is why msDS-UserPasswordExpiryTimeComputed is the best practical attribute to query in scripts for accurate expiry timing. [learn.microsoft.com]
How Sending Email Works in Graph
Microsoft Graph provides the sendMail action:
POST /users/{id | userPrincipalName}/sendMail- The request supports JSON bodies with
messageand optionalsaveToSentItems. - On success, it returns HTTP 202 Accepted. [learn.microsoft.com]
If you want inline images or attachments, you can use fileAttachment objects. A fileAttachment requires @odata.type and base64-encoded contentBytes, and supports isInline + contentId for CID images. [learn.microsoft.com], [learn.microsoft.com]
Testing Strategy (Before Going Live)
A simple and safe rollout pattern:
- Add a
TESTMODEswitch - When enabled, override all recipients to your own test address
- Validate formatting, images, and warning-day logic
- Flip
TESTMODEoff for production
This pattern reduces accidental mass emailing during development.
Troubleshooting Tips
“Access denied” when calling /sendMail
- Confirm
Mail.SendApplication permission is granted and admin-consented. [learn.microsoft.com], [github.com] - If you used mailbox restrictions (Application Access Policy/RBAC for apps), confirm your sender mailbox is included. [learn.microsoft.com]
Inline images not showing
- Ensure attachments include:
@odata.type = "#microsoft.graph.fileAttachment"contentBytesbase64isInline = truecontentIdmatchescid:contentIdin HTML [learn.microsoft.com], [learn.microsoft.com]
Graph connection issues
- Confirm the certificate exists in the certificate store accessible to the script account.
- Confirm you are calling
Connect‑MgGraphwith-TenantId -ClientId -CertificateThumbprint.
<#To $actualRecipient -Subject $subject -Html $html -Attachments $inlineAttachments
Write-Host "Sent to $actualRecipient for $($u.DisplayName) (daysLeft=$daysLeft)"
}
# Optional admin summary
if ($AdminSummaryRecipient -and $adminNoMail.Count -gt 0) {
$summaryBody = "<p>The following users had no detectable primary SMTP address:</p><ul>" +
($adminNoMail | ForEach-Object { "<li>$_</li>" } | Out-String) +
"</ul><p><i>Script credits: Antonio Rennvick Annoson</i></p>"
Send-GraphMail -FromUpn $SenderUPN -To $AdminSummaryRecipient -Subject "[Summary] Password Expiry Reminder - Missing Email" -Html $summaryBody
}
Disconnect-MgGraph | Out-Null
``
.SYNOPSIS
Active Directory Password Expiry Reminder - Email via Microsoft Graph (App-Only Certificate)
.DESCRIPTION
Queries AD for user password expiry using msDS-UserPasswordExpiryTimeComputed and sends
reminder emails via Microsoft Graph /sendMail using certificate-based app-only authentication.
.AUTHOR
Antonio Rennvick Annoson
.NOTES
- Replace placeholders in the CONFIG section.
- Requires: RSAT ActiveDirectory module + Microsoft.Graph PowerShell SDK.
#>
$ErrorActionPreference = "Stop"
# ----------------------------
# CONFIG (EDIT THESE)
# ----------------------------
# When days remaining matches one of these values, send a reminder.
$WarningIntervals = @(30, 14, 7, 3, 1, 0)
# Test mode: when $true, all emails go ONLY to $TestRecipient
$TestMode = $true
$TestRecipient = "<YOUR_TEST_EMAIL@EXAMPLE.COM>"
# Mailbox used to send the messages (must be a licensed mailbox in M365)
$SenderUPN = "<SENDER_MAILBOX@EXAMPLE.COM>"
# Optional: admin summary recipient
$AdminSummaryRecipient = "<ADMIN_EMAIL@EXAMPLE.COM>"
# AD scope - you can set SearchBase to limit to a specific OU
# Example: "OU=Users,DC=example,DC=com"
$SearchBase = $null # $null = whole domain
# Graph app-only cert auth placeholders
$TenantId = "<TENANT_ID_GUID>"
$ClientId = "<APP_CLIENT_ID_GUID>"
$CertThumbprint = "<CERT_THUMBPRINT>"
# Optional: inline image folder (CID attachments)
$InlineImagesPath = "C:\Path\To\Images" # set to $null to disable inline images
# ----------------------------
Import-Module ActiveDirectory
function Connect-GraphAppOnly {
# Connect-MgGraph supports app-only authentication using certificate thumbprint. [3](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.authentication/connect-mggraph?view=graph-powershell-1.0)[4](https://github.com/MicrosoftDocs/microsoftgraph-docs-powershell/blob/main/microsoftgraph/docs-conceptual/app-only.md)
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $CertThumbprint -NoWelcome | Out-Null
}
function Get-PasswordExpiryDate {
param($AdUser)
# msDS-UserPasswordExpiryTimeComputed is computed by the DC and indicates when the password expires. [2](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/f9e9b7e2-c7ac-4db6-ba38-71d9696981e9)
$ft = [int64]$AdUser."msDS-UserPasswordExpiryTimeComputed"
if ($ft -le 0 -or $ft -eq [int64]::MaxValue) { return $null }
return [datetime]::FromFileTime($ft)
}
function Get-PrimarySmtp {
param($AdUser)
# Prefer uppercase SMTP: in proxyAddresses (primary SMTP convention).
$primary = $null
if ($AdUser.ProxyAddresses) {
$primary = $AdUser.ProxyAddresses | Where-Object { $_ -clike "SMTP:*" } | Select-Object -First 1
}
if ($primary) { return ($primary -replace "^SMTP:", "") }
# Fallback to 'mail' attribute if present
if ($AdUser.mail) { return $AdUser.mail }
return $null
}
function New-GraphInlineAttachments {
param([string]$Path)
if (-not $Path) { return @() }
if (-not (Test-Path $Path)) { return @() }
$attachments = @()
Get-ChildItem -Path $Path -File | ForEach-Object {
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
$b64 = [System.Convert]::ToBase64String($bytes)
# fileAttachment requires @odata.type + contentBytes (base64) and supports isInline/contentId. [6](https://learn.microsoft.com/en-us/graph/api/resources/fileattachment?view=graph-rest-1.0)
$attachments += @{
"@odata.type" = "#microsoft.graph.fileAttachment"
name = $_.Name
contentType = "application/octet-stream"
contentBytes = $b64
isInline = $true
contentId = $_.Name
}
}
return $attachments
}
function Send-GraphMail {
param(
[string]$FromUpn,
[string]$To,
[string]$Subject,
[string]$Html,
[array] $Attachments = @()
)
# user:sendMail supports JSON message payload and saveToSentItems. [1](https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0)
$payload = @{
message = @{
subject = $Subject
body = @{
contentType = "HTML"
content = $Html
}
toRecipients = @(
@{ emailAddress = @{ address = $To } }
)
attachments = $Attachments
}
saveToSentItems = $true
}
$json = $payload | ConvertTo-Json -Depth 30
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/users/$FromUpn/sendMail" `
-Body $json `
-ContentType "application/json" | Out-Null
}
function Build-ModernHtmlBody {
param(
[string]$DisplayName,
[int]$DaysLeft
)
$badgeColor = if ($DaysLeft -le 3) { "#c62828" } elseif ($DaysLeft -le 7) { "#f9a825" } else { "#2e7d32" }
$badgeText = if ($DaysLeft -eq 0) { "Expires today" } else { "Expires in $DaysLeft days" }
@"
<html>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:Segoe UI,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding:28px;">
<table width="650" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,0.08);overflow:hidden;">
<tr>
<td style="background:#0b5394;color:#fff;padding:18px 26px;">
<h2 style="margin:0;font-size:20px;">Password Expiry Reminder</h2>
</td>
</tr>
<tr>
<td style="padding:18px 26px;">
<span style="display:inline-block;background:$badgeColor;color:#fff;padding:6px 12px;border-radius:20px;font-size:12px;">
$badgeText
</span>
<p style="margin:14px 0 8px 0;font-size:14px;color:#222;">Hello <b>$DisplayName</b>,</p>
<p style="margin:0 0 10px 0;font-size:14px;color:#333;">
Your Windows password is approaching expiration. Please update it promptly to avoid disruption.
</p>
<p style="margin:0 0 10px 0;font-size:13px;color:#555;">
<b>Tip:</b> You can change your password even while travelling, as long as your device has internet access.
</p>
<h3 style="margin:16px 0 10px 0;color:#0b5394;font-size:15px;">How to change your password</h3>
<ol style="margin:0;padding-left:18px;font-size:13px;color:#333;">
<li style="margin-bottom:8px;">Press <b>CTRL + ALT + DEL</b> and choose <b>Change a password</b>.</li>
<li style="margin-bottom:8px;">Enter your current password, then your new password twice, and confirm.</li>
</ol>
<h3 style="margin:16px 0 10px 0;color:#0b5394;font-size:15px;">Password requirements</h3>
<ul style="margin:0;padding-left:18px;font-size:13px;color:#333;">
<li>Minimum 12 characters</li>
<li>Must not contain your username</li>
<li>Cannot reuse your last 3 passwords</li>
<li>Must include 3 of the following: uppercase, lowercase, number, special character</li>
</ul>
</td>
</tr>
<tr>
<td style="background:#f0f2f5;padding:14px 26px;font-size:11px;color:#666;">
This notification is generated automatically to remind users of upcoming password expiry.
<br>
<i>Script credits: Antonio Rennvick Annoson</i>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"@
}
# ----------------------------
# MAIN
# ----------------------------
Connect-GraphAppOnly
$inlineAttachments = New-GraphInlineAttachments -Path $InlineImagesPath
$filter = "Enabled -eq 'True' -and PasswordNeverExpires -eq 'False'"
$users = if ($SearchBase) {
Get-ADUser -SearchBase $SearchBase -LDAPFilter "(objectCategory=person)" -Properties `
DisplayName, mail, ProxyAddresses, msDS-UserPasswordExpiryTimeComputed, Enabled, PasswordNeverExpires
} else {
Get-ADUser -Filter * -Properties `
DisplayName, mail, ProxyAddresses, msDS-UserPasswordExpiryTimeComputed, Enabled, PasswordNeverExpires
}
$adminNoMail = New-Object System.Collections.Generic.List[string]
foreach ($u in $users) {
if (-not $u.Enabled) { continue }
if ($u.PasswordNeverExpires) { continue }
$expiry = Get-PasswordExpiryDate -AdUser $u
if (-not $expiry) { continue }
$daysLeft = (New-TimeSpan -Start (Get-Date) -End $expiry).Days
if ($WarningIntervals -notcontains $daysLeft) { continue }
$to = Get-PrimarySmtp -AdUser $u
if (-not $to) {
$adminNoMail.Add("$($u.DisplayName)")
continue
}
$actualRecipient = if ($TestMode) { $TestRecipient } else { $to }
$subject = if ($daysLeft -eq 0) { "Action Required: Your password expires today" } else { "Action Required: Password expires in $daysLeft day(s)" }
if ($TestMode) { $subject = "[TEST] $subject (original recipient masked)" }
$html = Build-ModernHtmlBody -DisplayName $u.DisplayName -DaysLeft $daysLeft
