In this tutorial, we will show how to send Microsoft 365 password expiration notification emails to users using PowerShell and Microsoft Graph API.
Introduction
When Microsoft removed password expiration from Microsoftโs Cybersecurity Baseline recommendations, the password expiration notification email in Microsoft 365 went along with it.
Microsoft 365 administrators cannot set a password expiration notification for their tenants. This situation may be acceptable if they follow Microsoftโs recommendation to turn off password expiration. But administrators are left to fill the gap for those who opt to keep their password expiration policies.
The solution mentioned below is intended for Microsoft Entra ID cloud-managed passwords. Note that in hybrid environments where password policies are managed by on-premises AD, Microsoft Graph may not provide the info needed to determine the effective password expiration date. In this case, you need to calculate the expiration date from the on-premises AD password policy/retrieve the computed expiration time from the msDS-UserPasswordExpiryTimeComputed attribute. Our article focuses on cloud-managed Microsoft Entra ID passwords.
Requirements
- A computer with Windows PowerShell 5.1 or PowerShell 7.x. This post uses PowerShell 7.x.
- The Microsoft Graph PowerShell SDK must be installed on your computer. Install the latest version from the PowerShell Gallery with the following command Install-Module Microsoft.Graph
- A mailbox to use as the email notification sender. A shared mailbox can be used in many environments, provided that the mailbox exists in Exchange Online and the app has sufficient permissions to send mail (the app must have Mail.Send application permission with admin consent granted).
Prep Work: Register an App in Microsoft Entra ID
This step is crucial for automating the Microsoft 365 password expiration notification as an unattended scheduled job. Running the task under your personal administrator credentials is not recommended because it risks exposure and will not work if the account is MFA-enabled.
For Azure-hosted automation. In case you plan to run this script from Azure Automation/Azure Function, you should consider using a managed identity instead of storing a client secret or certificate. Managed identities eliminate the need to manage app credentials and can be assigned the required Microsoft Graph permissions directly. The certificate-based method we have described below is intended primarily for scripts running on a Windows host.
But if you plan to manually run the Microsoft 365 password expiration notification, you can skip this prep work.
Register a New Microsoft Entra ID App
- Log in to your Microsoft Entra admin center.
- Navigate to Identity โ Applications โ App registrations โ New registration.

- Under โRegister an applicationโ, fill out the form as follows:
- Name: โMicrosoft 365 Password Expiration Notificationโ
- Supported account types: โAccounts in this organizational directory only (Single tenant)โ
- Redirect URI: โWebโ, โhttp://localhostโ
Once satisfied, click Register.
- Copy and record the Application (client) ID and Directory (tenant) ID.

Add API Permissions
- Once registered, click โAPI permissionsโ and โAdd a permission.โ

- Click the โMicrosoft Graphโ button.

- Select โApplication Permissions.โ

- Find and enable the following permissions: โUser.Read.Allโ, โMail.Sendโ, โDomain.Read.Allโ. Once youโve selected the permissions, click the โAdd permissionsโ button.

- Next, click the โGrant admin consent for โ button.


Notice the โStatusโ changes to โGrantedโ.
Note that only a Global Administrator can grant consent to the tenant. If youโre not a Global Administrator, ask someone else to grant this consent for you.
Add a Certificate
- Open PowerShell on your computer and run the following commands.
# Specify the app or certificate name
$CertificateOrAppName = 'Microsoft 365 Password Expiration Notification'
# Generate a self-signed certificate
$certSplat = @{
Subject = $CertificateOrAppName
NotBefore = ((Get-Date).AddDays(-1))
NotAfter = ((Get-Date).AddYears(3))
CertStoreLocation = "Cert:\CurrentUser\My"
Provider = "Microsoft Enhanced RSA and AES Cryptographic Provider"
HashAlgorithm = "SHA256"
KeySpec = "KeyExchange"
KeyExportPolicy = "Exportable"
}
$selfSignedCertificate = New-SelfSignedCertificate @certSplat
# Export the public certificate.
$selfSignedCertificate | Export-Certificate -FilePath ".\$CertificateOrAppName.cer"
This command creates a new self-signed certificate in the Personal certificate store (Cert:\CurrentUser\My) and saves the public certificate in the current working directory. The private certificate (with the private key) remains in your Personal certificate store. Keep it secure.
Note. For production/enterprise environments, you should consider storing certificates/secrets in a centralized secrets-management solution (such as Azure Key Vault) instead of relying on a certificate stored in a specific user’s local certificate store. This makes certificate rotation and moving the scheduled task to another server easier to manage.
Note the location of your certificate. Youโll need it later. - Click Certificates & secrets โ Certificates โ Upload certificate.

- Locate and select the public certificate and click Add.

- Once the certificate has been uploaded, copy and record the โThumbprintโ value. Youโll need this thumbprint to authenticate.

Add a Secret
You can also add a secret in place of a self-signed certificate. Note that certificate-based authentication is still the more secure method, but a secret-based credential is also valid.
- Under Certificates & Secrets, click Client secrets โ New client secret.

- Enter the description and choose when the secret will expire. Click Add.

- Copy the secret value and store it as if it were a password.

Set Sending Microsoft 365 Password Expiration Notification
Step 1: Connect to Microsoft Graph PowerShell
Assuming all requirements are in place, the first step is to connect to the Microsoft Graph PowerShell. The connection command varies based on the method to use.
You should check that the expected authentication type, tenant ID, and permissions are displayed before proceeding.
# Using Delegated Access Connect-MgGraph -Scopes 'User.Read.All', 'Mail.Send', 'Domain.Read.All' # Use App-only access with a client secret credential. This method requires additional safeguards to avoid exposing the secret key.
$ClientSecret = ConvertTo-SecureString $Secret -AsPlainText -Force
$Credential = New-Object System.Management.Automation.PSCredential(
$ClientId,
$ClientSecret
)
Connect-MgGraph -TenantId $TenantId -ClientSecretCredential $Credential # Use App-only access with a certificate Connect-MgGraph -ClientId <client ID> -TenantId <tenant ID> -CertificateThumbprint <thumbprint> Connect-MgGraph -ClientId <client ID> -TenantId <tenant ID> -Certificate (Get-Item CERT:\CurrentUser\My\<thumbprint>)
In this example, Iโm using app-only access with a certificate.
Connect-MgGraph ` -TenantId 'c58f0e93-31cf-4c4c-8b73-e962e8503595' ` -ClientId '31e823b9-45d4-40fb-9b86-c571b0f24b0e' ` -CertificateThumbprint '056A40BC2B5ADE70DC09C89F2188D6C05EE34FCE'

Confirm that the expected authentication type, tenant ID, client ID, and Graph context are displayed correctly with the command:
Get-MgContext

Note. Tenant ID, Client ID, and certificate thumbprint identify the tenant, app, and certificate and are not secrets. The certificate’s private key is sensitive and must be protected. You should not export/store the private key in source code/unsecured config files.
For production deployments, you should consider storing certificates and other sensitive credentials in a centralized secrets-management solution such as Azure Key Vault.
Step 2: Retrieve the Password Expiration Settings of All Domains
In most cases, the password expiration setting for the tenant is uniform for every domain. But in some cases, each domain may have a different set of password expiration settings, and we have to take it into account.
First, letโs get the tenant domains to determine their password expiration age. This command excludes those domains whose PasswordValidityPeriodInDays value is 2147483647.
- If the PasswordValidityPeriodInDays = 2147483647, the passwords for that domain do not expire.
- If the PasswordValidityPeriodInDays value is empty, you should check the effective password expiration policy in your tenant. In some Microsoft 365 environments, a null value may indicate that passwords do not expire.
$domains = Get-MgDomain |
Where-Object {
$_.PasswordValidityPeriodInDays -and
$_.PasswordValidityPeriodInDays -ne 2147483647
} |
Select-Object Id, PasswordValidityPeriodInDays $domains
As you can see below, only one domain has the password expiration enabled (for 110 days).

Step 3: Get Microsoft 365 Users Password Expiration Date
Letโs get all usersโ password expiration dates and remaining days. Weโll do this by calculating the expected expiration date based on the PasswordValidityPeriodInDays of the userโs domain.
But first, letโs define which user properties to retrieve.
$properties = "UserPrincipalName", "mail", "displayName", "PasswordPolicies", "LastPasswordChangeDateTime", "CreatedDateTime"
Note that the PasswordPolicies property can contain multiple comma-separated values (for example, DisablePasswordExpiration and DisableStrongPassword). Therefore, the script checks whether the DisablePasswordExpiration flag is present rather than comparing the property to a single exact value.
Next, run this command to get all enabled users and exclude accounts that have the DisablePasswordExpiration flag set:
$users = Get-MgUser -Filter "userType eq 'member' and accountEnabled eq true" `
-Property $properties -CountVariable userCount `
-ConsistencyLevel Eventual -All -PageSize 999 -Verbose | `
Select-Object $properties | Where-Object {
$_.PasswordPolicies -notmatch 'DisablePasswordExpiration' -and "$(($_.userPrincipalName).Split('@')[1])" -in $($domains.id)
} Note that PasswordPolicies may contain multiple comma-separated values.
Next, letโs add more custom properties to the $users objects.
$users | Add-Member -MemberType NoteProperty -Name Domain -Value $null $users | Add-Member -MemberType NoteProperty -Name MaxPasswordAge -Value 0 $users | Add-Member -MemberType NoteProperty -Name PasswordAge -Value 0 $users | Add-Member -MemberType NoteProperty -Name ExpiresOn -Value (Get-Date '1970-01-01') $users | Add-Member -MemberType NoteProperty -Name DaysRemaining -Value 0
Now, letโs iterate through each user and populate the additional properties. Note that some users may not have a LastPasswordChangeDateTime value (for example, newly created cloud-only accounts, service accounts, or certain synchronized identities). These accounts should be skipped/handled separately when calculating password expiration dates.
# Get the current datetime for calculation
$timeNow = Get-Date
foreach ($user in $users) {
# Skip users that do not have a LastPasswordChangeDateTime value.
if (-not $user.LastPasswordChangeDateTime) {
continue
}
# Get the user's domain
$userDomain = ($user.userPrincipalName).Split('@')[1]
# Get the maximum password age based on the domain password policy.
$maxPasswordAge = ($domains | Where-Object { $_.id -eq $userDomain }).PasswordValidityPeriodInDays
# Skip the user if the PasswordValidityPeriodInDays is 2147483647, which means no expiration.
if ($maxPasswordAge -eq 2147483647) {
continue;
}
$passwordAge = (New-TimeSpan -Start $user.LastPasswordChangeDateTime -End $timeNow).Days
$expiresOn = (Get-Date $user.LastPasswordChangeDateTime).AddDays($maxPasswordAge)
$user.Domain = $userDomain
$user.maxPasswordAge = $maxPasswordAge
$user.passwordAge = $passwordAge
$user.expiresOn = $expiresOn
$user.daysRemaining =
(New-TimeSpan -Start $timeNow -End $expiresOn).Days
} Finally, letโs display the password expiration dates for each user.
$users | Sort-Object DaysRemaining | Format-Table UserPrincipalName, DisplayName, Mail, PasswordAge, ExpiresOn, DaysRemaining
The result below shows each user, their password age, expiration date, and remaining days before expiration.

Step 4: Send the Microsoft 365 Password Expiration Notification Email
Now that we have a list of users with their password expiration dates, itโs time to send them the notification email.
First, define which users will be notified based on how many days remain before their passwords expire. For example, notify users whose passwords will expire in 31, 17, 14, 10, 5, 3, and 1 days. Note that expired passwords (DaysRemaining โค 0) are excluded from this notification example.
# Specify which days remaining will be notified. $PasswordNotificationWindowInDays = @(31, 17, 14, 10, 5, 3, 1)
Note. This example sends notifications only when the number of remaining days exactly matches one of the values in $PasswordNotificationWindowInDays. If the scheduled task does not run on a particular day, that notification window may be skipped. For production environments that require guaranteed delivery, you should consider implementing notification tracking and retry logic.
Next, specify the email address that will be the notification sender. If youโre using app-only authentication, the sender can be any valid mailbox. If youโre using delegated authentication, the sender address must be your mailbox.
# Specify the sender's email address. $SenderEmailAddress = 'PasswordExpirationNotification@lazyexchangeadmin.cyou'
Note that the notification recipient address is determined by the value of the Mail attribute (or UserPrincipalName if you modified the script to use it as a fallback). In environments where the UserPrincipalName differs from the primary SMTP address, the notification may be sent to a different address than the user’s sign-in name. You should review your directory attributes before deploying the script in production.
Note. Some Microsoft 365 tenants do not populate the Mail attribute for all users. In such environments, you may use the UserPrincipalName value as the notification email address instead.
Finally, the code below will iterate through each user and send them the Microsoft 365 password expiration notification email if they match the DaysRemaining threshold.
# Send Microsoft 365 password expiration notification
foreach ($user in $users) {
# Guard clause if the user's DaysRemaining value is not within -
# the $PasswordNotificationWindowInDays
# and has no email address (can't send the user an email)
$recipient = if ($user.Mail) {
$user.Mail
}
else {
$user.UserPrincipalName
}
if ($user.DaysRemaining -notin $PasswordNotificationWindowInDays) {
continue
}
# Compose the message
$mailBody = @()
$mailBody += '<!DOCTYPE html><html><body>'
$mailBody += "<p>Dear, $($user.DisplayName)</p>"
$mailBody += "<p>The password for your Microsoft 365 account ($($user.UserPrincipalName)) will expire on <b>$(get-date $user.ExpiresOn -Format D)</b>."
$mailBody += "<br>Please change your password soon to avoid interruption to your access.</p>"
$mailBody += "<p>Thank you. - The IT Team</p>"
$mailBody += '</body></html>'
# Create the mail object
$mailObject = @{
Message = @{
ToRecipients = @(
@{
EmailAddress = @{
Address = $recipient
}
}
)
Subject = "Your Microsoft 365 password will expire in $($user.DaysRemaining) day(s)"
Body = @{
ContentType = "HTML"
Content = ($mailBody -join "`n")
}
}
SaveToSentItems = $false
}
# Send the Microsoft 365 Password Expiration Notification Email
try {
"Sending password expiration notice to [$($user.displayName)] [Expires in: $($user.daysRemaining) days] [Expires on: $($user.expiresOn)]" | Out-Default
Send-MgUserMail -BodyParameter $mailObject -UserId $SenderEmailAddress
}
catch {
"There was an error sending the notification to $($user.displayName)" | Out-Default
$_.Exception.Message | Out-Default
}
}
# Disconnect from Microsoft Graph
Disconnect-MgGraph
Wait until all notification emails are sent to matching users. Note that users without a populated Mail attribute can still receive notifications if their UserPrincipalName contains a valid email address.

Checking the userโs mailbox, hereโs the example Microsoft 365 password expiration notification message.

Wrapping Up
Thereโs no one-size-fits-all solution to sending Microsoft 365 password expiration notification emails. Your requirements may be different, and that’s OK. You can take the script provided in this post and modify it to suit your needs. All we did was show you the fundamentals, and itโs up to you to make improvements.
Does Microsoft 365 include built-in password expiration notification emails?
No. Microsoft removed password expiration from its security baseline recommendations, and Microsoft 365 no longer provides a built-in password expiration notification feature for tenants that still enforce password expiration.
What happens if the scheduled task misses a notification day?
That notification may never be sent because the script checks for exact day matches.
For guaranteed delivery, consider implementing:
- Notification tracking
- Retry logic
- Notification history logging
Which mailbox sends the notification emails?
Any valid Exchange Online mailbox can be used when employing app-only authentication.
Common choices include:
- Shared mailboxes
- IT support mailboxes
- Service accounts
Why might the notification be sent to an unexpected address?
In some organizations:
- UserPrincipalName differs from the primary email address.
- Mail attributes are incomplete.
- Hybrid synchronization creates differences between identities.
Review directory attributes before deployment.
What should the notification email include?
Typical information includes:
- User name
- Account name
- Password expiration date
- Days remaining
- Instructions for changing the password
- IT support contact information



Hi,
Thanks for the script …
When i run my script there is no result, just a black screen like this:
PS C:\Tools\APP_Registration> .\Invoke-PasswordExpirationNotification.ps1
PS C:\Tools\APP_Registration>
Users are not receiving e-mail.
Is there any option to test this first by putting only one account in?
Thanks,
Rene
When you do Steps1 to 3, does the $users variable return a list of user with expiring passwords? If not, then its possible that your $PasswordNotificationWindowInDays values didn’t match any users’ DaysRemaining result.
This worked great using powershell from a managed PC. However, i was wondering would it be possible to run a similar script using Azure Automate Runbooks? Ive attempted but keep getting an error with the certificate thumbprint
I followed the steps up until step 2 (retreive password expiration settings) and got this error:
Get-MgDomain : Insufficient privileges to complete the operation.
Status: 403 (Forbidden)
ErrorCode: Authorization_RequestDenied
Any idea what I did wrong?
Never mind I figured it out. My scope wasn’t properly set up. I used the right scope and it is working now.
This is problematic as it needs a dedicated Windows workstation to run the script. Which means it will need to either run manually or credentials would need to be cached locally to run an a schedule.
Is there anyway to run this natively via an Azure Runbook instead?
Hello, do you know how to add second email notification for those who have already password expired?
I don’t want to send them email that your password will expire in 0 days if it is already after expiration.
Thanks