A cloud-based identity and access management service for securing user authentication and resource access
It's 2026 and the problem is still relevant. I have two solutions.
The fist is fully in-house thing and could help you setup monitoring if your team uses Azure DevOps, since it fully relies on the pipeline functionality.
The second one is a third party tool, but it solves the monitoring and reporting in, literally, a few minutes.
In-house solution: (up to 1h to set it up)
- Create a new pipeline in ADO (blank for now)
- Create a new service connection in ADO (Project Settings -> Service Connections -> New). Type: Azure Resource Manager, Workload Identity Federation, access to all resource groups. Remember the name of the service connection, you'll need it
- Grant permission to read apps data to the service connection. Azure, Entra ID -> App Registration -> Found the one was automatically created for the service connection -> Api Permissions -> Microsoft Graph -> App permissions -> Application.Read.All. After adding it, don't forget to click on the admin consent right above the table with permissions.
- Go to the created pipeline now, and paste the code below
trigger: none
schedules:
- cron: "0 6 * * *" # 06:00 UTC nightly — adjust to your timezone
displayName: Nightly credential expiry check
branches:
include:
- main
always: true
pool:
vmImage: ubuntu-latest
variables:
warnDays: 30
azureServiceConnection: 'service-connection-name' # set this to your service connection name
steps:
- task: AzureCLI@2
displayName: 'Check Entra app credential expiry'
inputs:
azureSubscription: $(azureServiceConnection)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
set -euo pipefail
THRESHOLD=$(date -u -d "+${WARN_DAYS} days" +%Y-%m-%dT%H:%M:%SZ)
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Checking for credentials expiring before $THRESHOLD"
az ad app list --all \
--query "[].{name:displayName, appId:appId, secrets:passwordCredentials, certs:keyCredentials}" \
-o json > apps.json
RESULTS=$(jq --arg threshold "$THRESHOLD" --arg now "$NOW" '
[.[] |
.name as $name |
.appId as $appId |
(.secrets[]? | select(.endDateTime != null and .endDateTime < $threshold) |
{application: $name, appId: $appId, credential: .displayName, type: "Secret", expiresUtc: .endDateTime,
status: (if .endDateTime < $now then "EXPIRED" else "Expiring" end)}),
(.certs[]? | select(.endDateTime != null and .endDateTime < $threshold) |
{application: $name, appId: $appId, credential: .displayName, type: "Certificate", expiresUtc: .endDateTime,
status: (if .endDateTime < $now then "EXPIRED" else "Expiring" end)})
]' apps.json)
COUNT=$(echo "$RESULTS" | jq 'length')
echo "Found $COUNT expiring/expired credential(s)"
echo "$RESULTS" | jq -r '.[] | "\(.status)\t\(.application)\t\(.credential)\t\(.type)\texpires \(.expiresUtc)"' | column -t -s$'\t'
echo "$RESULTS" > $(Build.ArtifactStagingDirectory)/expiry-report.json
echo "##vso[task.setvariable variable=expiringCount]$COUNT"
env:
WARN_DAYS: $(warnDays)
- task: PublishBuildArtifacts@1
displayName: 'Publish expiry report'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)/expiry-report.json'
ArtifactName: 'expiry-report'
- task: AzureCLI@2
displayName: 'Send email via Graph'
condition: gt(variables['expiringCount'], 0)
inputs:
azureSubscription: $(azureServiceConnection)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
set -euo pipefail
BODY_HTML=$(jq -r '
"<table border=1 cellpadding=6><tr><th>Application</th><th>Credential</th><th>Type</th><th>Status</th><th>Expires (UTC)</th></tr>" +
(map("<tr><td>" + .application + "</td><td>" + .credential + "</td><td>" + .type + "</td><td>" + .status + "</td><td>" + .expiresUtc + "</td></tr>") | join("")) +
"</table>"
' $(Build.ArtifactStagingDirectory)/expiry-report.json)
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/users/******@yourdomain.com/sendMail" \
--body "{
\"message\": {
\"subject\": \"Entra credential expiry: $(expiringCount) item(s)\",
\"body\": {\"contentType\": \"HTML\", \"content\": $(jq -Rs . <<< \"$BODY_HTML\")},
\"toRecipients\": [{\"emailAddress\": {\"address\": \"******@yourdomain.com\"}}]
}
}"
As a result, it published a file with expiration report and send via sendMail (Graph, to keep it fully in-house). You need to keep in mind, that the sender should have the license assigned for sending it. If you don't want to mess with it, you can use any 3rd party email sending services, usually it just an api call.
The fastest way these days: (up to 3 minutes)
Use AZ Token Watch. It utilizes the same Application.Read.All permission (as the script above, and it never sees secret values) and send email alerts, it's completely free and you don't need to maintain it. I've built it, when had to setup the monitoring system 3-rd time and realized, that I don't want to maintain these scripts anymore.