How to Send Password Expiry Reminder Emails with PowerShell and Microsoft Graph API (Certificate Auth)

Spread the love

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}/sendMail to send via a mailbox.

Architecture (How it Works)

Flow:

  1. PowerShell runs on a scheduled server (Task Scheduler).
  2. Script queries AD users and reads msDS-UserPasswordExpiryTimeComputed. [learn.microsoft.com]
  3. If user is within your warning intervals (e.g., 14 days left), script builds a branded HTML email.
  4. Script authenticates to Graph with Connect‑MgGraph app‑only cert auth.
  5. Script calls Graph /sendMail to 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:

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 message and optional saveToSentItems.
  • 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:

  1. Add a TESTMODE switch
  2. When enabled, override all recipients to your own test address
  3. Validate formatting, images, and warning-day logic
  4. Flip TESTMODE off for production

This pattern reduces accidental mass emailing during development.


Troubleshooting Tips

“Access denied” when calling /sendMail

Inline images not showing

Graph connection issues

  • Confirm the certificate exists in the certificate store accessible to the script account.
  • Confirm you are calling Connect‑MgGraph with -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

Leave a Reply

Your email address will not be published. Required fields are marked *