---
title: "Find Expiring App Registration Secrets Before an Outage"
description: "Report expiring app registration secrets and certificates with PowerShell and Microsoft Graph, route each one to its owner, and rotate it safely."
canonical: "https://adamtheautomator.com/find-expiring-app-registration-secrets/"
---

# Find Expiring App Registration Secrets Before an Outage

> Report expiring app registration secrets and certificates with PowerShell and Microsoft Graph, route each one to its owner, and rotate it safely.

Source: https://adamtheautomator.com/find-expiring-app-registration-secrets/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Find Expiring App Registration Secrets Before an Outage](https://adamtheautomator.com/wp-content/uploads/publisher/3ec5d9c85b2b81178e9ac5b3109d7e40/a17dc7e937f939221a5e25aadd5f5749c67f0e262d984e9b032a40d083fe81f4.png)

# Find Expiring App Registration Secrets Before an Outage

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)16 October 202610 min. read

Categories: [Cloud](/category/cloud/)

Tags:[Microsoft Entra](/tag/microsoft-entra/)[Microsoft Graph](/tag/microsoft-graph/)[PowerShell](/tag/powershell/)[Security](/tag/security/)

Table of Contents

*   [Why App Registration Secrets Expire and What Breaks](#why-app-registration-secrets-expire-and-what-breaks)
*   [Why a lapsed credential stays invisible](#why-a-lapsed-credential-stays-invisible)
*   [Prerequisites and the Graph Permission You Need](#prerequisites-and-the-graph-permission-you-need)
*   [Query Expiring Credentials with the Graph REST API](#query-expiring-credentials-with-the-graph-rest-api)
*   [The fields that survive in the report](#the-fields-that-survive-in-the-report)
*   [Get the Same Report with the Microsoft Graph PowerShell SDK](#get-the-same-report-with-the-microsoft-graph-powershell-sdk)
*   [Flatten the two credential arrays](#flatten-the-two-credential-arrays)
*   [Page and Filter the Directory Without Hitting 429s](#page-and-filter-the-directory-without-hitting-429s)
*   [Handle nextLink and 429 responses](#handle-nextlink-and-429-responses)
*   [Compare Your Monitoring Options](#compare-your-monitoring-options)
*   [Find the Owner and Rank Each Expiry by Blast Radius](#find-the-owner-and-rank-each-expiry-by-blast-radius)
*   [Rotate Safely with the Create-Before-Delete Order](#rotate-safely-with-the-create-before-delete-order)
*   [Recover When a Secret Has Already Expired](#recover-when-a-secret-has-already-expired)
*   [Prevent Recurrence, Then Schedule the Report](#prevent-recurrence-then-schedule-the-report)
*   [Turn the list into a routine](#turn-the-list-into-a-routine)

Which secrets in your [Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/what-is-entra) tenant expire in the next 60 days? Ask that out loud in a change meeting and watch the room go quiet, because the honest answer for most administrators is an afternoon of clicking, and the app they skim past is the one that breaks.

Answering that question in one command is the point of this tutorial. You will connect to [Microsoft Graph](https://learn.microsoft.com/en-us/graph/overview) and read the expiry dates that a default app listing hides, then route every expiring credential to the person who owns it. By the end you will have a scheduled check that names each credential and its owner weeks before a login stops working.

## Why App Registration Secrets Expire and What Breaks

Every credential on an app registration carries a start date and an end date. A client secret, which Microsoft calls an application password, is the string your app presents to prove who it is when it requests a token. Microsoft caps how long that string can live: the Microsoft Learn page on [adding app credentials](https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials) states, “Client secret lifetime is limited to two years (24 months) or less. You can’t specify a custom lifetime longer than 24 months.” That ceiling turns credential hygiene into a recurring calendar event, and a four-year-old tenant has already cycled every secret at least twice.

### Why a lapsed credential stays invisible

The failure gives no warning. When a secret or certificate passes its endDateTime, the application keeps running and every dashboard stays green, because the only broken piece is the token request. Nobody gets paged. The support ticket appears later instead, usually about a single integration that stopped syncing, and by then you are hunting for the app that owns the orphaned credential.

Industry data backs the pattern. [Keyfactor’s review of 2023 certificate outages](https://www.keyfactor.com/blog/2023s-biggest-certificate-outages-what-we-can-learn-from-them/) documents the Cisco SD-WAN device certificates that expired in May 2023 and disrupted cloud, storage, and e-commerce tools for more than 20,000 customers. One unattended credential rarely has a small blast radius.

The lifecycle below shows where monitoring has to fire, well before the credential’s endDateTime, so the warning lands while someone can still act on it.

![Client secret lifecycle](https://adamtheautomator.com/wp-content/uploads/publisher/2e1eabe15bdb9f0edd3ba2200f1bb56f04b8ae594d06c99a54dd73aecd4ecab5.png)

## Prerequisites and the Graph Permission You Need

Reading credential metadata is a directory operation, so you need a tenant you can read and an identity that carries the correct Graph permission. If you want to follow along hands-on, you will need:

*   A Microsoft Entra ID tenant with at least one app registration that holds a secret or certificate. That app is your test subject; a directory with no registrations returns an empty report.
    
*   The [Microsoft Graph PowerShell SDK](https://learn.microsoft.com/en-us/powershell/microsoftgraph/installation), specifically the Microsoft.Graph.Authentication and Microsoft.Graph.Applications modules, which provide Connect-MgGraph and Get-MgApplication. Skip this if you will call the REST endpoints with Invoke-RestMethod instead.
    
*   An account or managed identity with the [Application.Read.All](https://learn.microsoft.com/en-us/graph/permissions-reference) permission. Without it, Graph withholds the passwordCredentials and keyCredentials collections, so the query returns app names with empty credential arrays and looks successful. A delegated user also needs a read role. Application Administrator and Global Reader both qualify.
    
*   Application.ReadWrite.All, but only if you want the script to delete expired credentials as well as report them. Add it when you run the cleanup pass in the rotation section.
    
*   Mail.Send, but only if the script will email owners directly. Skip it when you post the report to a Teams webhook or a log instead.
    

Confirm the modules before you go further. Run `Get-Module Microsoft.Graph.Applications -ListAvailable` and check that a version returns; the examples here use the Microsoft Graph REST v1.0 endpoints and the SDK v2 line on PowerShell 7.

## Query Expiring Credentials with the Graph REST API

The application object is the blueprint Microsoft Entra ID stores in the tenant where the app was registered, and the credentials attach to that object. The service principal represents the app locally in each tenant, and a custom app’s secret is managed on the application object. Microsoft documents the split in [application and service principal objects in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals).

The catch is that [listing applications](https://learn.microsoft.com/en-us/graph/api/application-list?view=graph-rest-1.0) trims the response. Bare GETs return identity fields and omit the credential collections, so a script that forgets to ask for them reports that every app is healthy. Name the properties you want with the OData $select parameter:

```text
GET https://graph.microsoft.com/v1.0/applications?$select=id,displayName,appId,passwordCredentials,keyCredentials
Authorization: Bearer <access-token>
```

Requested this way, each application object returns two arrays. passwordCredentials holds client secrets, and keyCredentials holds certificates.

### The fields that survive in the report

Both arrays expose an endDateTime in ISO 8601 UTC, and that property drives every later decision. The remaining metadata tells you which credential you are looking at:

| Field | Array | Meaning |
| --- | --- | --- |
| endDateTime | both | UTC date and time the credential expires |
| keyId | both | GUID that identifies the credential |
| displayName | both | Friendly name set at creation |
| hint | passwordCredentials | First three characters of the secret value |
| customKeyIdentifier | keyCredentials | Certificate thumbprint |

Null endDateTime values deserve their own flag. No expiry date means the credential never rotates, so it is the opposite of a coverage gap: it is a lifetime credential someone created through PowerShell or the API, and it belongs on a review list, kept separate from the expiring-credential alerts. The [passwordCredential reference](https://learn.microsoft.com/en-us/graph/api/resources/passwordcredential?view=graph-rest-1.0) settles a question that trips up rotation scripts: “The generated password value is only returned during the initial POST request to addPassword. There is no way to retrieve this password in the future.” Capture the secret when you create it, because the tenant will not hand it back a second time.

## Get the Same Report with the Microsoft Graph PowerShell SDK

The SDK wraps the same REST calls behind cmdlets and saves you the paging code. [Get-MgApplication](https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.applications/get-mgapplication) takes a Property list that maps to $select and an All switch that follows the paging links for you:

```powershell
Connect-MgGraph -Scopes "Application.Read.All"
$apps = Get-MgApplication -All -Property 'Id,AppId,DisplayName,PasswordCredentials,KeyCredentials'
```

The All switch is what makes this work in a large tenant, and the Property list is what makes it correct. Drop Property and the cmdlet returns objects with empty credential arrays, the same false clean bill of health the raw REST call gives you.

### Flatten the two credential arrays

Each application object nests its secrets and certificates, so the report you actually want is flat. This loop walks both arrays and emits one row per credential, with the remaining days already calculated against a UTC clock:

```powershell
$now = (Get-Date).ToUniversalTime()
$rows = foreach ($app in $apps) {
  foreach ($cred in $app.PasswordCredentials) {
    [pscustomobject]@{
      App      = $app.DisplayName
      Type     = 'Client secret'
      KeyId    = $cred.KeyId
      Expires  = [datetime]$cred.EndDateTime
      DaysLeft = [math]::Floor((([datetime]$cred.EndDateTime) - $now).TotalDays)
    }
  }
  foreach ($cred in $app.KeyCredentials) {
    [pscustomobject]@{
      App      = $app.DisplayName
      Type     = 'Certificate'
      KeyId    = $cred.KeyId
      Expires  = [datetime]$cred.EndDateTime
      DaysLeft = [math]::Floor((([datetime]$cred.EndDateTime) - $now).TotalDays)
    }
  }
}
$rows | Where-Object { $_.DaysLeft -le 60 } | Sort-Object DaysLeft
```

The UTC detail is a real trap. Subtracting a local Get-Date from a UTC timestamp shifts the result by your offset, which can push a credential across a 30-day threshold and hide one that expires tomorrow morning.

## Page and Filter the Directory Without Hitting 429s

The cmdlet hides paging; the REST call does not. [Listing applications](https://learn.microsoft.com/en-us/graph/api/application-list?view=graph-rest-1.0) documents a default page size of 100 objects and a maximum of 999, so a tenant with thousands of registrations spans many pages. Each response carries an @odata.nextLink that you follow until it stops appearing.

### Handle nextLink and 429 responses

```powershell
$uri = 'https://graph.microsoft.com/v1.0/applications?$select=id,displayName,appId,passwordCredentials,keyCredentials&$top=999'
$all = @()
do {
  $page = Invoke-MgGraphRequest -Method GET -Uri $uri
  $all += $page.value
  $uri = $page.'@odata.nextLink'
} while ($uri)
```

Throttling is the second limit you will meet. Microsoft Graph meters identity and access calls with a token bucket, and the [service-specific throttling limits](https://learn.microsoft.com/en-us/graph/throttling-limits) page shows that a GET on applications costs two ResourceUnits against the per-tenant bucket, while adding $select lowers that cost. When you exceed the bucket, Graph answers with HTTP 429 and usually a Retry-After header. Honor that header and back off, because retrying immediately keeps the bucket empty and stretches the outage.

Filtering on credentials is worth its own caution. The credential arrays are not reliably filterable on the server, so the dependable approach is to page the whole directory and filter the rows in memory with Where-Object.

* * *

_**Key Insight: Prefer $select on every applications call. It trims the response and, on the identity service, lowers the ResourceUnit cost of each request.**_

* * *

## Compare Your Monitoring Options

Five approaches show up in real tenants, and they differ less in what they can read than in where they run and who they wake.

| Approach | Runs where | Reaches owners | Watch out for |
| --- | --- | --- | --- |
| Native Entra recommendation | Entra admin center | Directory roles only | Fixed 30-day window, single notice |
| Raw Graph REST | Any script host | Whatever you build | You own paging and throttling |
| Microsoft Graph PowerShell SDK | Scheduled task or runbook | Whatever you build | Module updates on the host |
| Azure Automation runbook | Azure, managed identity | Mail.Send or a webhook | Runbook runtime limits |
| Logic Apps flow | Azure, low code | Mail or Teams connector | Per-run cost and connector limits |

The built-in option cannot carry the job alone. [Microsoft Entra recommendations](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/recommendation-renew-expiring-application-credential) raises a credential only when it is “expiring within the next 30 days,” and the notice stops at directory roles, reaching no app owner. Teams working inside a 90-day change freeze cannot act on a 30-day warning, which is the gap a scheduled script fills. Pick the SDK script when you want one artifact you can read and version, and pick a Logic Apps flow when you want the run hosted and the notification handled by connectors you do not maintain.

## Find the Owner and Rank Each Expiry by Blast Radius

Expiry data without owners is a to-do list with no assignee. Owners are a separate collection, so pull them per app. [Listing an application’s owners](https://learn.microsoft.com/en-us/graph/api/application-list-owners?view=graph-rest-1.0) returns directory objects, and the user principal name sits in the additional properties:

```powershell
foreach ($app in $apps) {
  $owners = Get-MgApplicationOwner -ApplicationId $app.Id -All
  $upns = $owners |
    ForEach-Object { $_.AdditionalProperties['userPrincipalName'] } |
    Where-Object { $_ }
  [pscustomobject]@{ App = $app.DisplayName; Owners = ($upns -join '; ') }
}
```

The uncomfortable finding is the ownerless app, and a single filtered call surfaces them using the eventual consistency header the [applications list](https://learn.microsoft.com/en-us/graph/api/application-list?view=graph-rest-1.0) documents:

```text
GET https://graph.microsoft.com/v1.0/applications?$filter=owners/$count eq 0&$count=true&$select=id,displayName
ConsistencyLevel: eventual
```

Rank the output by blast radius before you send it. The ownerless app with a secret expiring in nine days needs a name attached today, while a certificate on a retired integration can wait for the cleanup pass. Sort by DaysLeft, then by whether an owner exists, then by where the appId shows up in your configuration store. The appId that appears in three pipelines is a higher-priority row than one that appears nowhere.

* * *

_**Pro Tip: Give every ownerless app a fallback owner in the report, even if it is a shared distribution list. An empty owner column is the row everyone skips.**_

* * *

## Rotate Safely with the Create-Before-Delete Order

You cannot push out an existing expiry. The endDateTime is locked when the credential is created, so rotation means issuing a replacement and retiring the old one. [application: addPassword](https://learn.microsoft.com/en-us/graph/api/application-addpassword?view=graph-rest-1.0) creates the new secret and returns the secretText exactly once; [removePassword](https://learn.microsoft.com/en-us/graph/api/application-removepassword?view=graph-rest-1.0) deletes the entry you name.

```text
POST https://graph.microsoft.com/v1.0/applications/{id}/addPassword
Content-type: application/json

{ "passwordCredential": { "displayName": "Rotation 2026-07" } }
```

```text
POST https://graph.microsoft.com/v1.0/applications/{id}/removePassword
Content-type: application/json

{ "keyId": "<old-credential-key-id>" }
```

Create the new credential and deploy it to the workload. Confirm the workload authenticates, and only then remove the old entry. Deleting first opens a window where nothing can authenticate, and that window is an outage you scheduled yourself. Certificates follow the same sequence with addKey and removeKey. The two orderings below show the difference between an uneventful rotation and one that pages you.

![Rotation order comparison](https://adamtheautomator.com/wp-content/uploads/publisher/9ee4cca7d73381b7a7cc7433bd766b12360c55c00d9a9a85c7897e6361c0f1a2.png)

* * *

_**Warning: addPassword and removePassword change a production identity. Test on a non-production app first, and prove the new credential authenticates before you delete the old one.**_

* * *

## Recover When a Secret Has Already Expired

Sometimes the report arrives too late and the outage is already live. Microsoft’s guidance on [recovering from an invalid client secret in AKS](https://learn.microsoft.com/en-us/troubleshoot/azure/azure-kubernetes/create-upgrade-delete/error-code-badrequest-or-invalidclientsecret) and its companion [service principal validation error reference](https://learn.microsoft.com/en-us/troubleshoot/azure/azure-kubernetes/error-codes/serviceprincipalvalidationclienterror-error) name the two errors that point straight at the cause: AADSTS7000222, “The provided client secret keys for app ‘…’ are expired,” and AADSTS7000215, “Invalid client secret provided.”

Recovery is the rotation sequence run under pressure. Create a replacement credential and update the workload’s configuration. Restart or redeploy the workload, then confirm success with a real token request instead of a config diff. Then close the gap that allowed the lapse. Confirm the app had an owner, and confirm the scheduled report reached that owner. Any expired secret that no report flagged is a coverage bug, and you close a coverage bug by widening the query.

## Prevent Recurrence, Then Schedule the Report

Automated discovery treats the symptom. The durable fix removes the expiring secret from the picture. Microsoft’s [security best practices for app registration](https://learn.microsoft.com/en-us/entra/identity-platform/security-best-practices-for-app-registration) state it directly: “Don’t use password credentials, also known as secrets.” Two replacements cover most workloads:

1.  A [managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) for anything running in Azure, which authenticates with no stored credential to expire.
    
2.  A [workload identity federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation) trust for workloads outside Azure, where GitHub Actions and Kubernetes assert the identity through the external platform and no Entra secret exists to renew.
    

When neither fits yet, switch a secret to a certificate. Certificates are the recommended credential type whenever a secret cannot be avoided, and their expiry still needs watching, so keep the report running through the migration.

### Turn the list into a routine

Schedule the report instead of running it when you remember. Run it from an [Azure Automation runbook](https://learn.microsoft.com/en-us/azure/automation/enable-managed-identity-for-automation) that holds Application.Read.All through a system-assigned managed identity and needs no stored credentials on the host; the same script also runs as a scheduled task on a server you already own. Set thresholds that match your change process: a 60-day list for planning, a 14-day list for action, and a daily list once a credential enters the danger window. Send the consolidated list to the platform team and the per-owner lines to each owner.

Expiry keeps coming, because the 24-month ceiling guarantees a rolling wave. The secret that breaks your Tuesday usually belongs to an app nobody owns, on a date nobody tracked. The weekly report that names the credential and its owner gives you a ticket you can schedule instead of an outage you discover, and the 60-day list stays empty.

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Ffind-expiring-app-registration-secrets%2F&text=Find%20Expiring%20App%20Registration%20Secrets%20Before%20an%20Outage)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Ffind-expiring-app-registration-secrets%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Ffind-expiring-app-registration-secrets%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/publisher/2e05d9c85b2b81c080b8d45ec1cdfbb3/71dd588b2369d52e1695c95c1493383c6c0ed2adb4aac7eba04c1ab324f3a6a6.webp)

### [Stop Service Principal Sprawl in Entra ID with PowerShell](/stop-service-principal-sprawl-entra-id-powershell/)

Inventory every app registration and service principal, then automate ownership, rotation, and safe disablement with PowerShell and Microsoft Graph.

![](https://adamtheautomator.com/wp-content/uploads/2026/06/featured_image-13.png)

### [Taming AI Tool Sprawl: A PowerShell Guide to Auditing and Governing Unauthorized AI Applications](/taming-ai-tool-sprawl-powershell-guide-auditing/)

Detect and govern unauthorized AI tools with PowerShell, Microsoft Graph, Entra ID, and Defender for Cloud Apps.

![](https://adamtheautomator.com/wp-content/uploads/publisher/2e05d9c85b2b819f9960cc3af514f9a0/4edae5c1327c7aaa91f7cd7fb033f216b4ba0310906a424978cd0f2b4bca4345.webp)

### [Azure Data Sovereignty Guardrails: Cut Jurisdictional Risk](/azure-data-sovereignty-guardrails/)

Master Azure data sovereignty with residency policies, encryption keys, operator oversight, and compliance controls to prevent jurisdictional data access.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
