Expired application secrets are one of the most common causes of silent production outages in Microsoft 365 environments. An app registration in Entra ID powers everything from automated workflows and API integrations to third-party SaaS connectors. When its client secret expires, the authentication simply stops working, and without proactive alerting, your team scrambles to diagnose what broke and why.
In this post, we will build a PowerShell script that connects to Microsoft Graph, scans all app registrations in your tenant for expiring secrets and certificates, and sends an email alert with a summary table. You can schedule it to run weekly so nothing slips through the cracks.
Prerequisites
- PowerShell 7.x (recommended) or Windows PowerShell 5.1
- The Microsoft.Graph PowerShell SDK (or at minimum the
Microsoft.Graph.ApplicationsandMicrosoft.Graph.Users.Actionssub-modules) - An Entra ID account with the Application.Read.All and Mail.Send Graph API permissions (delegated or application, depending on how you run the script)
- For unattended/scheduled runs: an app registration with a client secret or certificate, granted the above permissions as application permissions
Install the required modules if you have not already:
Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Force
Install-Module Microsoft.Graph.Users.Actions -Scope CurrentUser -Force Connecting to Microsoft Graph
For interactive testing you can sign in with your own account using Connect-MgGraph. For a scheduled, unattended run you authenticate as a service principal using a client secret or certificate stored in a secure location such as Azure Key Vault or a GitHub Actions secret.
# Interactive sign-in (for testing)
Connect-MgGraph -Scopes "Application.Read.All", "Mail.Send"
# Unattended sign-in using a client secret
$tenantId = "YOUR_TENANT_ID"
$clientId = "YOUR_APP_CLIENT_ID"
$clientSecret = "YOUR_CLIENT_SECRET" # Retrieve from Key Vault in production
$secureSecret = ConvertTo-SecureString $clientSecret -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($clientId, $secureSecret)
Connect-MgGraph -TenantId $tenantId -ClientSecretCredential $credential Scanning for Expiring Secrets and Certificates
The core of the script retrieves all application objects and inspects their PasswordCredentials (client secrets) and KeyCredentials (certificates). We flag anything expiring within a configurable number of days, defaulting to 60.
$warningDays = 60
$today = Get-Date
$expiring = [System.Collections.Generic.List[PSObject]]::new()
# Retrieve all app registrations (select only the fields we need)
$apps = Get-MgApplication -All `
-Property "DisplayName,AppId,PasswordCredentials,KeyCredentials"
foreach ($app in $apps) {
# Check client secrets
foreach ($secret in $app.PasswordCredentials) {
if ($null -eq $secret.EndDateTime) { continue }
$daysLeft = ($secret.EndDateTime - $today).Days
if ($daysLeft -le $warningDays) {
$expiring.Add([PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
Type = "Client Secret"
Name = $secret.DisplayName
ExpiryDate = $secret.EndDateTime.ToString("yyyy-MM-dd")
DaysLeft = $daysLeft
})
}
}
# Check certificates
foreach ($cert in $app.KeyCredentials) {
if ($null -eq $cert.EndDateTime) { continue }
$daysLeft = ($cert.EndDateTime - $today).Days
if ($daysLeft -le $warningDays) {
$expiring.Add([PSCustomObject]@{
AppName = $app.DisplayName
AppId = $app.AppId
Type = "Certificate"
Name = $cert.DisplayName
ExpiryDate = $cert.EndDateTime.ToString("yyyy-MM-dd")
DaysLeft = $daysLeft
})
}
}
}
Write-Host "Found $($expiring.Count) expiring credential(s)." Building and Sending the Alert Email
Once we have the list of expiring items, we build an HTML email body and send it via the Microsoft Graph sendMail endpoint. This avoids any dependency on an SMTP relay, making it ideal for cloud-only environments.
function Send-ExpiryAlertEmail {
param(
[string]$RecipientEmail,
[string]$SenderEmail,
[System.Collections.Generic.List[PSObject]]$ExpiringItems
)
# Build the HTML table rows
$rows = foreach ($item in $ExpiringItems | Sort-Object DaysLeft) {
$color = if ($item.DaysLeft -le 14) { "#ffcccc" }
elseif ($item.DaysLeft -le 30) { "#fff3cd" }
else { "#ffffff" }
"<tr style='background-color:$color'>
<td style='padding:8px;border:1px solid #ddd'>$($item.AppName)</td>
<td style='padding:8px;border:1px solid #ddd'>$($item.Type)</td>
<td style='padding:8px;border:1px solid #ddd'>$($item.Name)</td>
<td style='padding:8px;border:1px solid #ddd'>$($item.ExpiryDate)</td>
<td style='padding:8px;border:1px solid #ddd;font-weight:bold'>$($item.DaysLeft)</td>
</tr>"
}
$tableHtml = $rows -join "`n"
$body = @"
<html><body style='font-family:Segoe UI,sans-serif'>
<h2>Entra ID App Registration Expiry Alert</h2>
<p>The following credentials are expiring within <strong>$warningDays days</strong>.
Please rotate them before they expire to avoid service disruption.</p>
<table style='border-collapse:collapse;width:100%'>
<thead>
<tr style='background:#0078d4;color:#fff'>
<th style='padding:10px;text-align:left'>App Name</th>
<th style='padding:10px;text-align:left'>Type</th>
<th style='padding:10px;text-align:left'>Credential Name</th>
<th style='padding:10px;text-align:left'>Expiry Date</th>
<th style='padding:10px;text-align:left'>Days Left</th>
</tr>
</thead>
<tbody>$tableHtml</tbody>
</table>
<p style='color:#888;font-size:12px'>Generated by the Entra ID Secret Monitor script on $(Get-Date -Format 'yyyy-MM-dd').</p>
</body></html>
"@
$mailBody = @{
message = @{
subject = "ACTION REQUIRED: Entra ID App Secrets Expiring Soon"
body = @{ contentType = "HTML"; content = $body }
toRecipients = @(@{ emailAddress = @{ address = $RecipientEmail } })
}
saveToSentItems = $false
}
Send-MgUserMail -UserId $SenderEmail -BodyParameter $mailBody
Write-Host "Alert email sent to $RecipientEmail"
}
# Only send if there is something to report
if ($expiring.Count -gt 0) {
Send-ExpiryAlertEmail `
-RecipientEmail "[email protected]" `
-SenderEmail "[email protected]" `
-ExpiringItems $expiring
} else {
Write-Host "No credentials expiring within $warningDays days. No email sent."
} Putting It All Together
Combine all the pieces into a single script file and save it as Watch-AppSecretExpiry.ps1. You can then schedule it using a few different approaches depending on your environment:
- Windows Task Scheduler: Run the script daily or weekly using a service account that has the required permissions.
- Azure Automation Runbook: Upload the script as a PowerShell runbook, add the Microsoft.Graph module from the module gallery, and store the client secret in an Automation credential asset.
- GitHub Actions: Store your tenant ID, client ID, and client secret as repository secrets and trigger the workflow on a schedule using a
cronexpression.
Here is a minimal GitHub Actions workflow that runs the check every Monday morning:
name: Entra App Secret Monitor
on:
schedule:
- cron: '0 7 * * 1' # Every Monday at 07:00 UTC
workflow_dispatch:
jobs:
check-secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Graph modules
shell: pwsh
run: |
Install-Module Microsoft.Graph.Applications -Force -Scope CurrentUser
Install-Module Microsoft.Graph.Users.Actions -Force -Scope CurrentUser
- name: Run expiry check
shell: pwsh
env:
TENANT_ID: ${{ secrets.ENTRA_TENANT_ID }}
CLIENT_ID: ${{ secrets.ENTRA_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.ENTRA_CLIENT_SECRET }}
run: ./Watch-AppSecretExpiry.ps1 Summary
With around 100 lines of PowerShell, you now have a repeatable, automated process that keeps your team ahead of Entra ID credential expiry. The script covers both client secrets and certificates, color-codes the alert table by urgency, and sends everything through Microsoft Graph without any external dependencies. Pair it with a scheduling mechanism that fits your environment, and you will never be caught off guard by a broken integration again.
Have questions or want to extend this to also open Jira or Azure DevOps tickets automatically? Drop a comment below.