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.Applications and Microsoft.Graph.Users.Actions sub-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 cron expression.

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.