Azure App Registrations Credentials Monitoring
You signed up with great intentions, forgot they exist, and now they’re causing a production outage on a Friday afternoon
Azure App Registrations Credentials Monitoring

Made with Google Nano Pro
You signed up with great intentions, forgot they exist, and now they’re causing a production outage on a Friday afternoon
Azure App Registrations are like gym memberships: you create them with the best intentions, they quietly sit in the background doing their job, and then — right when you’re trying to enjoy a weekend — they expire and take down half your production environment with them.
If your current credential monitoring strategy is “wait for the support tickets to roll in”, this is for you.
Client secrets don’t send you a calendar invite. They don’t ping you on Teams. They expire at midnight with the cold indifference of a SLA that nobody actually read, and you find out about it three hours later when someone finally escalates to the right person. I’ve lost more Friday afternoons to this than I care to admit — and I once spent two hours debugging a “mysterious authentication issue” that turned out to be a secret that expired at 00:01.
So I built something to fix that. A small, serverless Azure Function that checks all your App Registrations, flags anything about to expire, and sends you a clean weekly report. Set it up once, forget it exists (in a good way this time), and never debug an expired credential at 11pm again.
The “Set and Forget” Architecture
Azure Function App (Timer Trigger)
│
├── Managed Identity
│ ├── Graph API: Application.Read.All
│ └── Graph API: Mail.Send
│
└── Key Vault
├── mail-sender
└── mail-recipient
No client secrets in the monitoring tool itself — because in 2026, storing credentials in config files is how you end up in a post-mortem. The Function authenticates via Managed Identity, reads the mail addresses from Key Vault, and talks to the Graph API. Zero stored secrets, least-privilege permissions, and a Consumption plan that costs roughly nothing.
Prerequisites
- Azure CLI — and if you’re on Windows:
az.cmd, notaz. This will save you 20 minutes of confusion later, trust me. - PowerShell 7 (
pwsh), not Windows PowerShell 5.1. Yes, there's a difference. No, they're not interchangeable. - An Azure subscription with Contributor access
- Microsoft 365 / Exchange Online with a mailbox to send from — a Shared Mailbox works perfectly and needs no additional license
Step 1: Infrastructure
Configure the variables at the top of setup-appcredmon.ps1:
l
$subscriptionId = "<your-subscription-id>"
$resourceGroup = "<existing-resource-group>"
$location = "westeurope" # or any region next to your location
$suffix = "credmon01"
$mailSender = "monitoring@yourdomain.com"
$mailRecipient = "security@yourdomain.com"
$warningDays = "30"
$timerSchedule = "0 0 7 * * *" # daily at 07:00 UTC
Then run it:
az login
az account set --subscription $subscriptionId
pwsh -ExecutionPolicy Bypass -File .\setup-appcredmon.ps1
The script handles everything: Storage Account, Application Insights, Key Vault with the mail secrets, the Function App with its Managed Identity, RBAC assignments, and the Graph API permissions. At the end it prints a summary with the MI Principal ID — note that down, you might need it.
Step 2: Setting App Settings on Windows (The @ Problem)
Here’s a gotcha that will cost you time if you hit it blind: on Windows, az.cmd functionapp config appsettings set silently breaks when your setting value starts with @. The shell interprets it as a file reference and the command exits without setting anything, without an error, without any indication that something went wrong.
Key Vault References look like @Microsoft.KeyVault(...). So they always trigger this.
The fix: write the settings to a JSON file and pass the filepath instead:
$settingsFile = "$env:TEMP\mailsettings.json"
@"
[
{ "name": "MAIL_SENDER", "value": "monitoring@yourdomain.com" },
{ "name": "MAIL_RECIPIENT", "value": "security@yourdomain.com" }
]
"@ | Set-Content $settingsFile -Encoding UTF8
az.cmd functionapp config appsettings set `
--name func-credmon01 `
--resource-group <your-resource-group> `
--settings "@$settingsFile"
Remove-Item $settingsFile
Use this pattern for any App Setting on Windows. Not just Key Vault References — any time the value contains characters that your shell might want to interpret creatively.
Step 3: The Function Code
Four things worth explaining:
Token acquisition. On Functions (Consumption plan), the classic IMDS endpoint at 169.254.169.254 is blocked. The runtime exposes dedicated environment variables instead:
function Get-GraphToken {
$endpoint = $env:IDENTITY_ENDPOINT
$header = $env:IDENTITY_HEADER
$response = Invoke-RestMethod `
-Uri "${endpoint}?resource=https://graph.microsoft.com/&api-version=2019-08-01" `
-Headers @{ "X-IDENTITY-HEADER" = $header } `
-Method GET
return $response.access_token
}
Querying all App Registrations. Graph pages results, so follow @odata.nextLink until it runs out:
$uri = "https://graph.microsoft.com/v1.0/applications?`$select=displayName,appId,passwordCredentials,keyCredentials&`$top=999"
do {
$response = Invoke-RestMethod -Uri $uri -Headers $Headers -Method GET
$apps += $response.value
$uri = $response.'@odata.nextLink'
} while ($uri)
Checking both secrets and certificates. passwordCredentials are client secrets, keyCredentials are certificates. Both expire. Both cause outages.
Sending via Graph. The users/{sender}/sendMail endpoint requires the sender to be a real Exchange Online mailbox. A Shared Mailbox works fine and Graph is perfectly happy sending from it.
Step 4: One Gotcha With profile.ps1
Azure Functions generates a default profile.ps1 that calls Disable-AzContextAutosave and Connect-AzAccount at startup. Since we're not using the Az module at all — we authenticate directly via IDENTITY_ENDPOINT — this causes the function to fail before your code even runs.
Override it with a stub:
# profile.ps1
# Minimal by design — using IDENTITY_ENDPOINT for auth, Az module not needed
Include this in your deployment package. Without it, you’ll spend quality time reading CommandNotFoundException logs wondering why a command that has nothing to do with your code is breaking everything.
Step 5: Deploy and Test
cd function-code
Compress-Archive -Path .\* -DestinationPath ..\function.zip -Force
az.cmd functionapp deployment source config-zip `
--name func-credmon01 `
--resource-group <your-resource-group> `
--src ..\function.zip
For the test run: az functionapp function invoke only works for HTTP triggers, not Timer triggers. Use the admin endpoint with the master key instead:
$masterKey = az.cmd rest `
--method POST `
--url "https://management.azure.com/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.Web/sites/func-credmon01/host/default/listkeys?api-version=2022-03-01" `
--query "masterKey" --output tsv
Invoke-RestMethod `
-Uri "https://func-credmon01.azurewebsites.net/admin/functions/CheckAppCredentials" `
-Method POST `
-Headers @{ "x-functions-key" = $masterKey; "Content-Type" = "application/json" } `
-Body '{}'
No response is normal — Timer triggers don’t return anything. Check the Monitor tab in the Portal for logs.
What the Output Looks Like
A colour-coded HTML table lands in your inbox: red for already-expired credentials, yellow for anything hitting your warning window, white for things that are fine. The subject line includes a count of critical items — [3 CRITICAL] App Registration Expiry Report - 2026-02-20 — so you can set up an email filter and only open it when something actually needs attention.
The Schedule
6-field cron, seconds first. Azure always runs in UTC:
0 0 7 * * * → daily at 07:00 UTC (08:00 CET, 09:00 CEST)
0 0 7 * * 1-5 → weekdays only
0 0 6,14 * * * → twice daily
Change it any time via the Portal or CLI — no redeployment needed. It’s just an App Setting.
Go Fix Your Credentials
The full solution is about 300 lines of PowerShell and JSON. It runs on the Consumption plan, which is cheaper than the coffee you’ll need to fix the mess if you don’t use this. It needs no maintenance. It will quietly do its job and send you a boring “all OK” email most weeks, and a slightly alarming one when something actually needs attention.
That’s the point. Boring infrastructure is good infrastructure.
Get the Code
All files are on GitHub: **https://github.com/michaelhannecke/appcredmon**
The repo includes the infrastructure script (setup-appcredmon.ps1), all function code files, and a full deployment README with troubleshooting reference.
메타데이터
- post_id
- 8892ccae66a0
- slug
- azure-app-registrations-are-like-gym-memberships-8892ccae66a0
- url
- https://medium.com/@michael.hannecke/azure-app-registrations-are-like-gym-memberships-8892ccae66a0
- canonical_url
- https://medium.com/@michael.hannecke/azure-app-registrations-are-like-gym-memberships-8892ccae66a0
- author_url
- https://medium.com/@michael.hannecke
- status
- ok
- fetched_at
- 2026-08-18 06:12:11