Never Get Surprised by an Expired App Secret Again: Building an Automated Expiry Monitor with Azure Automation and Power Automate
How we built a fully automated monitor that scans thousands of Azure app registrations, finds every secret and certificate about to expire, looks up who owns each app, and delivers a clean HTML report to the inbox — every day, hands-free.
The Problem
If you run a Microsoft Entra ID (Azure AD) tenant of any real size, you know this pain: an application secret or certificate silently expires, an integration breaks at 2 AM, and nobody knows who owns the app that just took down a business process.
In our tenant the scale made manual tracking impossible:
- 321 App Registrations
- 4,321 Enterprise Apps (Service Principals)
- 4,321 credentials flagged on the very first scan — over 1,300 of them already expired
We needed a system that continuously answers three questions: what is expiring, when, and who owns it — without anyone having to remember to check.
The Architecture
The solution has two halves, glued together by a simple JSON payload:
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ Azure Automation Runbook │ HTTPS │ Power Automate Cloud Flow │
│ (PowerShell 7.2) │ ──────► │ (HTTP Request trigger) │
│ │ POST │ │
│ 1. Auth via Managed Id. │ JSON │ 1. Receive JSON payload │
│ 2. Query Graph API │ │ 2. Create HTML table │
│ 3. Evaluate expiry dates │ │ 3. Send formatted email │
│ 4. Look up app owners │ │ │
│ 5. Trigger the flow │ │ │
└─────────────────────────────┘ └──────────────────────────────┘
Why split it this way? The runbook is great at heavy lifting (thousands of Graph API calls, filtering, paging), while Power Automate is great at the last mile (beautiful HTML email, easy to re-style without touching code, easy to extend to Teams later).
Prerequisites
- An Azure Automation Account with System-Assigned Managed Identity enabled.
- The managed identity granted these Microsoft Graph application roles:
Application.Read.AllDirectory.Read.All
- A Power Automate flow with the "When an HTTP request is received" trigger.
Note on permissions: the solution is strictly read-only against Entra ID. It never modifies an app registration — it only reads credential metadata and the owners list.
The Complete Runbook
Here is the full runbook in one place. All identifiers below are synthetic — replace the subscription ID, flow URL, and recipient with your own values.
# ============================================================
# Runbook : AppReg-Secret-Expiry-Monitor
# Purpose : Monitor App Registration & Enterprise App
# secret/certificate expiry and email a report
# Runtime : PowerShell 7.2 (Azure Automation)
# ============================================================
# PRE-REQUISITE: System-assigned Managed Identity must be
# ENABLED on the Automation Account and granted these Graph
# API app roles in Entra ID:
# - Application.Read.All
# - Directory.Read.All
# ============================================================
Write-Output "========================================"
Write-Output "AppReg Secret/Cert Expiry Monitor"
Write-Output "Runbook started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Output "========================================"
# --- Config ---
$ThresholdDays = @(60, 30, 7)
$GraphBaseUrl = "https://graph.microsoft.com/v1.0"
$AlertRecipient = "iam-alerts@contoso.com"
# The HTTP POST URL copied from your Power Automate trigger card.
# It contains its own SAS key (sig=...) - treat it like a password!
$FlowInvokeUri = "https://contoso00000000000000000000.00.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/0000000000000000000000000000abcd/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=<YOUR-SAS-SIGNATURE>"
Write-Output "Alert thresholds : $($ThresholdDays -join ', ') days"
# ============================================================
# STEP 1 : Authenticate to Azure & Microsoft Graph
# ============================================================
Write-Output "[Step 1] Connecting with System-Assigned Managed Identity..."
Connect-AzAccount -Identity -ErrorAction Stop
Write-Output "[Step 1] Azure login successful."
Write-Output "[Step 1] Requesting Graph API access token..."
$graphTokenObj = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/" -ErrorAction Stop
$GraphHeaders = @{
"Authorization" = "Bearer $($graphTokenObj.Token)"
"Content-Type" = "application/json"
}
# Quick verification call + tenant name for the report
$org = Invoke-RestMethod -Uri "$GraphBaseUrl/organization" -Headers $GraphHeaders -Method Get
$tenantName = $org.value[0].displayName
Write-Output "[Step 1] Connected to tenant : $tenantName"
# ============================================================
# STEP 2 : Fetch all App Registrations and Enterprise Apps
# (paged - Graph returns max 100-999 items per page)
# ============================================================
function Get-AllGraphPages {
param([string]$Uri, [hashtable]$Headers)
$items = [System.Collections.Generic.List[object]]::new()
do {
$resp = Invoke-RestMethod -Uri $Uri -Headers $Headers -Method Get -ErrorAction Stop
foreach ($i in $resp.value) { $items.Add($i) }
$Uri = $resp.'@odata.nextLink'
} while ($Uri)
return $items
}
Write-Output "[Step 2] Fetching App Registrations..."
$appRegUri = "$GraphBaseUrl/applications?`$select=id,displayName,appId,passwordCredentials,keyCredentials"
$appRegs = Get-AllGraphPages -Uri $appRegUri -Headers $GraphHeaders
Write-Output "[Step 2] App Registrations found : $($appRegs.Count)"
Write-Output "[Step 2] Fetching Enterprise Apps (Service Principals)..."
$spUri = "$GraphBaseUrl/servicePrincipals?`$select=id,displayName,appId,passwordCredentials,keyCredentials"
$servicePrincipals = Get-AllGraphPages -Uri $spUri -Headers $GraphHeaders
Write-Output "[Step 2] Enterprise Apps found : $($servicePrincipals.Count)"
# ============================================================
# STEP 3 : Evaluate credential expiry dates
# + look up owners ONLY for apps that matter
# ============================================================
$Today = (Get-Date).Date
$AlertItems = [System.Collections.Generic.List[object]]::new()
function Invoke-ExpiryCheck {
param($Objects, [string]$ObjectType, $Thresholds, $Today, $Results)
foreach ($obj in $Objects) {
$name = $obj.displayName
$appId = $obj.appId
# Owners are fetched LAZILY - only when this app has a
# credential expiring within the alert window. This avoids
# thousands of unnecessary Graph calls per run.
$ownerEmails = $null
foreach ($credSet in @(
@{ Creds = $obj.passwordCredentials; Type = "Secret" },
@{ Creds = $obj.keyCredentials; Type = "Certificate" }
)) {
foreach ($cred in $credSet.Creds) {
if (-not $cred.endDateTime) { continue }
$expiry = [datetime]$cred.endDateTime
$daysLeft = ($expiry.Date - $Today).Days
$hitThreshold = $Thresholds | Where-Object { $daysLeft -le $_ } |
Sort-Object | Select-Object -First 1
if ($null -ne $hitThreshold) {
# Owner lookup (read-only) - only for credentials
# expiring within 60 days, once per app
if ($daysLeft -ge 0 -and $null -eq $ownerEmails) {
$ownerEndpoint = if ($ObjectType -eq "AppReg") { "applications" }
else { "servicePrincipals" }
$ownerEmails = "No owner assigned"
try {
$ownersResp = Invoke-RestMethod `
-Uri "$GraphBaseUrl/$ownerEndpoint/$($obj.id)/owners" `
-Headers $GraphHeaders -Method Get -ErrorAction Stop
$emails = $ownersResp.value | ForEach-Object {
if ($_.mail) { $_.mail } else { $_.userPrincipalName }
} | Where-Object { $_ }
if ($emails) { $ownerEmails = ($emails -join "; ") }
} catch { $ownerEmails = "Error fetching owners" }
}
$Results.Add(@{
ObjectType = $ObjectType; AppName = $name; AppId = $appId
CredType = $credSet.Type
CredName = $cred.displayName; CredId = $cred.keyId
ExpiryDate = $expiry.ToString("yyyy-MM-dd")
DaysLeft = $daysLeft; Threshold = $hitThreshold
IsExpired = ($daysLeft -lt 0); Owners = $ownerEmails
})
}
}
}
}
}
Write-Output "[Step 3] Evaluating credential expiry dates..."
Invoke-ExpiryCheck -Objects $appRegs -ObjectType "AppReg" `
-Thresholds $ThresholdDays -Today $Today -Results $AlertItems
Invoke-ExpiryCheck -Objects $servicePrincipals -ObjectType "EntApp" `
-Thresholds $ThresholdDays -Today $Today -Results $AlertItems
Write-Output "[Step 3] Total credentials flagged : $($AlertItems.Count)"
# ============================================================
# STEP 4 : Summarize
# ============================================================
$expired = @($AlertItems | Where-Object { $_.IsExpired })
$within7 = @($AlertItems | Where-Object { -not $_.IsExpired -and $_.DaysLeft -le 7 })
$within30 = @($AlertItems | Where-Object { -not $_.IsExpired -and $_.DaysLeft -le 30 -and $_.DaysLeft -gt 7 })
$within60 = @($AlertItems | Where-Object { -not $_.IsExpired -and $_.DaysLeft -le 60 -and $_.DaysLeft -gt 30 })
$allWithin60 = @($AlertItems | Where-Object { -not $_.IsExpired -and $_.DaysLeft -le 60 })
Write-Output ""
Write-Output "[Step 4] Expiry breakdown:"
Write-Output " Already EXPIRED : $($expired.Count)"
Write-Output " Expiring <= 7 days : $($within7.Count)"
Write-Output " Expiring 8-30 days : $($within30.Count)"
Write-Output " Expiring 31-60 days : $($within60.Count)"
$actionable = @($AlertItems | Where-Object { -not $_.IsExpired } | Sort-Object { $_.DaysLeft })
$topItems = $actionable | Select-Object -First 20 | ForEach-Object {
"$($_.ObjectType) | $($_.AppName) | $($_.CredType) | $($_.ExpiryDate) (expires in $($_.DaysLeft)d)"
}
# ============================================================
# STEP 5 : Trigger the Power Automate flow
# ============================================================
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$body = @{
recipient = $AlertRecipient
subject = "[$tenantName] App Credential Expiry Alert - $($actionable.Count) expiring within 60 days"
timestamp = $timestamp
tenant = $tenantName
totalFlagged = $AlertItems.Count
alreadyExpired = $expired.Count
expiringIn7d = $within7.Count
expiringIn30d = $within30.Count
expiringIn60d = $allWithin60.Count
credentials = @($allWithin60 | Select-Object ObjectType, AppName, AppId,
CredType, CredName, ExpiryDate, DaysLeft, Threshold, Owners)
topItems = ($topItems -join "`n")
} | ConvertTo-Json -Depth 5
Write-Output "[Step 5] Triggering Power Automate flow for $AlertRecipient..."
try {
# IMPORTANT: the URL already carries a SAS signature (sig=...),
# so DO NOT add an Authorization header - Logic Apps rejects
# requests that carry two authentication schemes at once.
Invoke-RestMethod -Uri $FlowInvokeUri -Method Post `
-Body $body -ContentType "application/json" -ErrorAction Stop
Write-Output "[Step 5] Flow triggered successfully - email will be dispatched by Power Automate."
}
catch {
Write-Error "[Step 5] Flow trigger failed: $($_.Exception.Message)"
throw
}
Write-Output ""
Write-Output "=== Runbook complete ==="
Write-Output "Scan finished: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
The Power Automate Flow
The flow is intentionally tiny — three steps:
1. Trigger — "When an HTTP request is received"
Paste this request body JSON schema (generated from a sample payload):
{
"type": "object",
"properties": {
"recipient": { "type": "string" },
"subject": { "type": "string" },
"timestamp": { "type": "string" },
"tenant": { "type": "string" },
"totalFlagged": { "type": "integer" },
"alreadyExpired": { "type": "integer" },
"expiringIn7d": { "type": "integer" },
"expiringIn30d": { "type": "integer" },
"expiringIn60d": { "type": "integer" },
"topItems": { "type": "string" },
"credentials": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ObjectType": { "type": "string" },
"AppName": { "type": "string" },
"AppId": { "type": "string" },
"CredType": { "type": "string" },
"CredName": { "type": ["string", "null"] },
"ExpiryDate": { "type": "string" },
"DaysLeft": { "type": "integer" },
"Threshold": { "type": "integer" },
"Owners": { "type": ["string", "null"] }
}
}
}
}
}
After you save the flow once, the trigger card displays the HTTP POST URL — that exact URL (including its sig= SAS key) is what goes into $FlowInvokeUri in the runbook.
2. Create HTML table
- From:
triggerBody()?['credentials'] - Columns: automatic — the table picks up
ObjectType,AppName,CredType,ExpiryDate,DaysLeft, and the newOwnerscolumn by itself.
3. Send an email (V2)
- To:
triggerBody()?['recipient'] - Subject:
triggerBody()?['subject'] - Body: summary counts (
expiringIn7d,expiringIn30d,expiringIn60d,alreadyExpired) followed by the HTML table output, with a little inline CSS to make the table presentable.
That's the whole flow. The email goes to a single distribution list — the app owners appear as information in a column, they are not recipients. (Per-owner notification is a clean future extension: loop over the credentials array, group by Owners, send each person only their own rows.)
The Complete Power Automate Flow Definition
If you prefer to build the flow from code instead of clicking through the designer, here is the complete flow definition. You can paste it via the Power Automate "Edit in code view" experience (or adapt it for a Logic Apps deployment). As everywhere in this article, all identifiers are synthetic — the connection reference GUID is generated for your environment automatically when you sign in to the Office 365 Outlook connector.
{
"$schema": "https://power-automate-tools.local/flow-editor.json#",
"connectionReferences": {
"shared_office365": {
"connectionName": "shared-office365-00000000-1111-2222-3333-444444444444",
"source": "Embedded",
"id": "/providers/Microsoft.PowerApps/apis/shared_office365",
"displayName": "Office 365 Outlook",
"tier": "Standard",
"apiName": "office365"
}
},
"definition": {
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"$connections": {
"defaultValue": {},
"type": "Object"
},
"$authentication": {
"defaultValue": {},
"type": "SecureObject"
}
},
"triggers": {
"manual": {
"type": "Request",
"kind": "Http",
"inputs": {
"method": "POST",
"triggerAuthenticationType": "All"
}
}
},
"actions": {
"Create_HTML_table": {
"runAfter": {},
"type": "Table",
"inputs": {
"from": "@triggerBody()?['credentials']",
"format": "HTML"
}
},
"Send_an_email_(V2)": {
"runAfter": {
"Create_HTML_table": [
"Succeeded"
]
},
"type": "OpenApiConnection",
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_office365",
"connectionName": "shared_office365",
"operationId": "SendEmailV2"
},
"parameters": {
"emailMessage/To": "iam-alerts@contoso.com",
"emailMessage/Subject": "[AppReg Alert] @{triggerBody()?['totalFlagged']} credentials expiring - Tenant: @{triggerBody()?['tenant']}",
"emailMessage/Body": "<p>Hello,</p><p>This is your daily <b>App Registration Secret/Certificate Expiry Alert</b>.</p><h3>Summary</h3><table border=\"1\" style=\"border-collapse:collapse;font-family:Arial,sans-serif;\"><tbody><tr><th style=\"padding:6px;background:#f0f0f0;\">Metric</th><th style=\"padding:6px;background:#f0f0f0;\">Count</th></tr><tr><td style=\"padding:6px;\">Total Flagged (within 60 days)</td><td style=\"padding:6px;\"><b>@{triggerBody()?['totalFlagged']}</b></td></tr><tr><td style=\"padding:6px;color:red;\">Already Expired</td><td style=\"padding:6px;color:red;\"><b>@{triggerBody()?['alreadyExpired']}</b></td></tr><tr><td style=\"padding:6px;color:red;\">Expiring within 7 days</td><td style=\"padding:6px;color:red;\"><b>@{triggerBody()?['expiringIn7d']}</b></td></tr><tr><td style=\"padding:6px;color:orange;\">Expiring in 8-30 days</td><td style=\"padding:6px;color:orange;\"><b>@{triggerBody()?['expiringIn30d']}</b></td></tr><tr><td style=\"padding:6px;color:#DAA520;\">Expiring in 31-60 days</td><td style=\"padding:6px;color:#DAA520;\"><b>@{triggerBody()?['expiringIn60d']}</b></td></tr></tbody></table><br><p><b>Tenant:</b> @{triggerBody()?['tenant']}<br><b>Report generated:</b> @{triggerBody()?['timestamp']}</p><p>Please review and renew credentials before they expire.</p><h3>Credentials Expiring Within 60 Days</h3>@{body('Create_HTML_table')}",
"emailMessage/Importance": "Normal"
},
"authentication": "@parameters('$authentication')"
}
}
},
"outputs": {}
}
}
Results
The monitor now runs on a schedule, and every morning the team gets a single email that answers everything at a glance:
| ObjectType | AppName | CredType | ExpiryDate | DaysLeft | Owners |
|---|---|---|---|---|---|
| AppReg | HR-Sync-Integration | Secret | 2026-08-19 | 14 | alex.doe@contoso.com |
| EntApp | Finance-API-Gateway | Certificate | 2026-09-02 | 28 | pat.roe@contoso.com |
| AppReg | Legacy-Batch-Loader | Secret | 2026-09-28 | 54 | No owner assigned |
No more 2 AM surprises. No more "who owns this app?" scavenger hunts. And the "No owner assigned" rows have turned into their own cleanup project — arguably the most valuable side effect of the whole exercise.
No comments:
Post a Comment