Articles

How to get an alert before an Entra app secret expires

Microsoft Entra ID sends no mail when a client secret or certificate on an app registration is about to expire. There is no notification setting on an app registration and no address to fill in. The first sign is usually an integration that stops signing in.

Does Entra warn you at all?

Only for one kind of credential. Enterprise applications that use SAML single sign-on have notification email addresses under Single sign-on, SAML Certificates, and Entra mails those addresses 60, 30 and 7 days before the signing certificate expires. App registration secrets and certificates have nothing like it.

The closest thing is the Renew expiring application credentials recommendation under Entra ID, Overview, Recommendations, which lists credentials due within 30 days. It is a page someone has to open, not a mail, and Microsoft lists it under the Workload ID license. So the alert has to be built. Here are three ways, cheapest first.

How do you send the alert from a PowerShell script?

The script below reads every app registration, keeps the secrets and certificates that expire in exactly 30, 14, 7 or 1 days, and sends one mail per recipient through Send-MgUserMail. Run it once a day so each window is hit once. To see everything that expires, not only today’s warnings, use the inventory in How to find expiring app registration secrets with PowerShell.

It needs two Graph permissions and no more: Application.Read.All to read applications and their owners, and Mail.Send to send. Install Microsoft.Graph.Applications and Microsoft.Graph.Users.Actions (which holds Send-MgUserMail).

# Written for the Microsoft Graph PowerShell SDK v2 (Microsoft.Graph.Applications, Microsoft.Graph.Users.Actions). Run it first against a test tenant.
param(
    [string] $From    = 'it-alerts@contoso.com',  # mailbox the warnings are sent from
    [string] $OnCall  = 'oncall@contoso.com',     # gets rows with no owner, and every row from 7 days
    [int[]]  $Windows = @(30, 14, 7, 1)
)
Connect-MgGraph -Scopes "Application.Read.All","Mail.Send" -NoWelcome

$now  = [datetime]::UtcNow
$apps = Get-MgApplication -All -Property "id,displayName,passwordCredentials,keyCredentials"

$due = foreach ($app in $apps) {
    $creds = @($app.PasswordCredentials | Select-Object @{n='Type';e={'Secret'}}, DisplayName, EndDateTime) +
             @($app.KeyCredentials      | Select-Object @{n='Type';e={'Certificate'}}, DisplayName, EndDateTime)
    $hits = @($creds | Where-Object { $_.EndDateTime -and
        [math]::Floor(($_.EndDateTime.ToUniversalTime() - $now).TotalDays) -in $Windows })
    if (-not $hits) { continue }

    $owners = @(Get-MgApplicationOwner -ApplicationId $app.Id -All | ForEach-Object {
        if ($_.AdditionalProperties.mail) { $_.AdditionalProperties.mail }
        else { $_.AdditionalProperties.userPrincipalName } } | Where-Object { $_ })
    if (-not $owners) { $owners = @($OnCall) }

    foreach ($c in $hits) {
        $days = [math]::Floor(($c.EndDateTime.ToUniversalTime() - $now).TotalDays)
        $to   = if ($days -le 7) { @($owners) + $OnCall | Select-Object -Unique } else { $owners }
        foreach ($address in $to) {
            [pscustomobject]@{ To = $address; Application = $app.DisplayName; Type = $c.Type
                Credential = $c.DisplayName; Expires = $c.EndDateTime.ToString('yyyy-MM-dd'); DaysLeft = $days }
        }
    }
}

foreach ($group in $due | Group-Object To) {
    $table = ($group.Group | Sort-Object DaysLeft |
        Select-Object Application, Type, Credential, Expires, DaysLeft | ConvertTo-Html -Fragment) -join ''
    $mail = @{
        message = @{
            subject      = "App credentials expiring soon: $($group.Count)"
            body         = @{ contentType = 'HTML'; content = "<p>Renew these before the date shown.</p>$table" }
            toRecipients = @(@{ emailAddress = @{ address = $group.Name } })
        }
        saveToSentItems = $false
    }
    Send-MgUserMail -UserId $From -BodyParameter $mail
}

Signed in as yourself, Mail.Send only sends as you, so set $From to your own address. If you would rather use an SMTP relay your mail system already accepts, keep the first half and replace Send-MgUserMail with your relay client; then the script needs only Application.Read.All.

How do you run it without anyone signed in?

From Task Scheduler. Create an app registration for the job, grant it the application permissions Application.Read.All and Mail.Send, and have an administrator consent. Give it a certificate, keep the private key on the machine that runs the job, and replace the Connect-MgGraph line with Connect-MgGraph -ClientId <app id> -TenantId <tenant id> -CertificateThumbprint <thumbprint> -NoWelcome. Schedule powershell.exe -NoProfile -File with the script’s path, daily, whether the user is signed in or not.

From an Azure Automation runbook. Turn on the Automation account’s system-assigned managed identity, import Microsoft.Graph.Authentication, Microsoft.Graph.Applications and Microsoft.Graph.Users.Actions at the same version, and connect with Connect-MgGraph -Identity -NoWelcome. There is no consent screen for a managed identity, so an administrator grants the two permissions once:

# Written for the Microsoft Graph PowerShell SDK v2 (Microsoft.Graph.Applications). Run it first against a test tenant.
Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All","Application.Read.All" -NoWelcome
$mi    = Get-MgServicePrincipal -Filter "displayName eq 'aa-credential-alerts'"   # the Automation account's name
$graph = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
foreach ($perm in 'Application.Read.All', 'Mail.Send') {
    $role = $graph.AppRoles | Where-Object { $_.Value -eq $perm -and $_.AllowedMemberTypes -contains 'Application' }
    New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $mi.Id -PrincipalId $mi.Id -ResourceId $graph.Id -AppRoleId $role.Id
}

Either way, the application permission Mail.Send can send as any mailbox in the tenant. Limit it to the one sending mailbox with Exchange Online RBAC for Applications. And put the job’s own certificate on its list: the app that warns about expiring credentials has one too.

What about a Logic App or Azure Monitor?

A Logic App can do the same job without a script: a daily Recurrence trigger, an HTTP action that calls GET /applications on Graph with the Logic App’s managed identity, a filter on the end dates, and the Office 365 Outlook connector to send. It works, but you have to follow @odata.nextLink yourself once the tenant has more than one page of applications, the date logic lives in expressions that are harder to review than a script, every run is billed per action, and the managed identity needs the same app role grant as above. Azure Monitor has no built-in signal for credential expiry: the Entra audit and sign-in logs record changes and sign-ins, not end dates, so an alert rule there only works once something else writes the dates into a Log Analytics workspace.

What if you will not run anything?

Then check by hand. In the Microsoft Entra admin center, go to Entra ID, App registrations, All applications. The Certificates & secrets column shows whether each application’s credentials are current, expiring soon or expired, so the ones to open stand out. Open an application and select Certificates & secrets to see each credential’s expiry date. Put a recurring calendar entry on it, weekly at least. It is fine for a dozen applications and the first thing to slip when the person who does it is away.

When should the mail go out, and to whom?

Four warnings work for most teams:

  1. 30 days. Time to reach a vendor and book a change.
  2. 14 days. The renewal should be scheduled by now.
  3. 7 days. Escalate: copy on-call, as the script does.
  4. 1 day. The last chance to renew before something stops.

The mail goes to the application’s owners in Entra, because they know what the application connects to. When an application has no owner, or the owner has left, the mail goes to an on-call mailbox, not to one person’s inbox. Then assign an owner, under App registrations, Owners, so the next warning has somewhere to go.

Checked against

  • Microsoft.Graph.Applications 2.41.2 (Get-MgApplication with PasswordCredentials, KeyCredentials, EndDateTime, DisplayName; Get-MgApplicationOwner with AdditionalProperties.mail and AdditionalProperties.userPrincipalName; Get-MgServicePrincipal with AppRoles, Value, AllowedMemberTypes; New-MgServicePrincipalAppRoleAssignment with -ServicePrincipalId, -PrincipalId, -ResourceId, -AppRoleId)
  • Microsoft.Graph.Users.Actions 2.41.2 (Send-MgUserMail with -UserId and -BodyParameter: message, subject, body, contentType, content, toRecipients, emailAddress, address, saveToSentItems)
  • Microsoft.Graph.Authentication 2.41.2 (Connect-MgGraph with -Scopes, -ClientId, -TenantId, -CertificateThumbprint and -Identity)
  • Module versions current on the PowerShell Gallery on 2026-10-16; Windows PowerShell 5.1 and PowerShell 7.4, with no operators that only PowerShell 7 has
  • Microsoft Entra admin center paths, checked by name against Microsoft Learn: Entra ID, App registrations, All applications, Certificates & secrets, Owners; Entra ID, Overview, Recommendations; Enterprise apps, Single sign-on, SAML Certificates

Would you rather not maintain it?

If you would rather not keep the script, the permissions and the schedule running, GlacierPoint Remind sends those warnings at 30, 14, 7 and 1 days before each app registration secret or certificate expires, with the owner named.

Start free