A cloud computing platform and infrastructure for building, deploying and managing applications and services through a worldwide network of Microsoft-managed datacenters.
Still a real problem in 2026, so adding an updated approach. (If you just want the fastest path and don't need to run anything yourself, there's a hosted option at the very end — scroll down.)
A couple of things worth adding to what's already here. The Get-AzureADApplication snippet won't run anymore (it was retired in 2025), so it's Microsoft Graph now. And another gap, suggested solutions monitor only secrets, but we'd like to monitor certificates as well.
Here's a current version that covers both. It runs in an Azure Automation runbook on a managed identity, so there's no stored secret that expires on you, and it POSTs the result to a webhook so you can send it wherever you want - Slack, Teams, you monitoring tool.
$select = 'id,displayName,appId,passwordCredentials,keyCredentials'
$apps = Get-GraphAll ('https://graph.microsoft.com/v1.0/applications?$select=' + $select + '&$top=999')
$sps = Get-GraphAll ('https://graph.microsoft.com/v1.0/servicePrincipals?$select=' + $select + '&$top=999')
if ($sps.Count -eq 0) { throw 'No service principals returned. Check the Application.Read.All assignment.' }
$now = [DateTime]::UtcNow
$cutoff = $now.AddDays(30)
$results = @()
function Get-ExpiringCreds {
param($List, [string]$ObjectType)
foreach ($o in $List) {
foreach ($pair in @(@('Secret', $o.passwordCredentials), @('Certificate', $o.keyCredentials))) {
foreach ($c in $pair[1]) {
if (-not $c.endDateTime) { continue }
$end = ([DateTimeOffset]$c.endDateTime).UtcDateTime
if ($end -ge $cutoff) { continue }
$script:results += [pscustomobject]@{
objectType = $ObjectType
displayName = $o.displayName
appId = $o.appId
credentialType = $pair[0]
credentialName = $c.displayName
expiresOn = $end.ToString('yyyy-MM-ddTHH:mm:ssZ')
daysLeft = [math]::Floor(($end - $now).TotalDays)
status = if ($end -lt $now) { 'Expired' } else { 'Expiring' }
}
}
}
}
}
Find the full script and setup guide, that works in 2026 in this gist.
Step by step:
- Create an automation account in Azure, create a runbook, use runtime 7.2, system assigned identity.
- Grant
Application.Read.Allas a Graph app role (you'll need to use cloud shell, seeNew-MgServicePrincipalAppRoleAssignment) - Put the code above in the runbook, publish.
- Create a Schedule, assign to a runbook.
- Run the runbook via the Test Pane to confirm it works.
The script is the easy part tbh. What builds up over time is the receiving end: the same credential shows up every night for a month, so you end up wanting dedupe on appId + credentialName, updating an existing ticket instead of opening a new one, and closing it once the credential stops appearing.
That maintenance is basically why I ended up building a hosted version - AZ Token Watch. Same read-only Application.Read.All, covers app registrations and enterprise apps, sends to email/Slack/Teams/webhook.
Two things it saves you: it can route each alert to the app's owner instead of one shared channel, and keeping up with Graph changes is on me, that surface has shifted a lot lately (AzureAD module retiring, old Teams webhooks going away, SMTP auth changes), and self-hosted scripts tend to break quietly when it does. Free tier, and setup is a few minutes since it's admin-consent, not a runbook to deploy. The script above is the better call if you'd rather own it end to end.