Find expiring Entra ID app secrets before production does

Written by Peculiar Cloud engineeringPublished

Expiring Entra ID credentials live in the passwordCredentials and keyCredentials of app registrations, and in the same two properties on service principals, where SAML signing certificates sit. One read-only Microsoft Graph permission, Application.Read.All, is enough to list them all and flag every endDateTime inside your warning window. The durable fix is fewer secrets: managed identities or workload identity federation where you can, and short-lived, owned credentials for what remains.

An expired Entra ID client secret doesn’t page anyone. The integration it powered just stops, and the first evidence is AADSTS7000222: The provided client secret keys are expired in somebody’s logs. The expiry date sat on the app’s Certificates & secrets page the whole time; nothing made anyone look at it. Here is how to look at all of them at once, with one read-only permission — and how to end up with fewer to look at.

Where the credentials live

Every secret and certificate you manage sits in one of two collections, on one of two object types, and each entry carries an endDateTime in UTC.

Object Property What you’ll find
App registration (application) passwordCredentials Client secrets
App registration (application) keyCredentials Certificates
Service principal (enterprise app) passwordCredentials Secrets added to the service principal directly; the password that protects a SAML signing key
Service principal (enterprise app) keyCredentials SAML signing certificates; certificates added directly

Two consequences follow. First, a review that only opens App registrations never sees anything attached to a service principal, including every SAML certificate. Second, SAML certificates are noisy in raw data: Graph stores a generated SAML signing certificate as three objects on the service principal (a Sign key, a Verify key and a password credential for the private key), all sharing one customKeyIdentifier. Expect three rows per certificate. Entra does email 60, 30 and 7 days before a SAML certificate expires, but by default only to the admin who added the application.

Leave managed identities out. Azure rotates their credentials itself; Microsoft currently documents a 90-day credential that is rolled after 45 days.

One permission, read-only

Application.Read.All is the least-privileged Microsoft Graph permission for both GET /applications and GET /servicePrincipals, as a delegated or an application permission, and both forms need admin consent. Run the script below interactively as a Global Reader, or grant the application permission to a scheduled job. Add a permission that can read users only when the report should name owners; without one, Graph returns each owner as a bare object ID. Anything with ReadWrite in its name has no business in a report.

The query

In REST terms, it is two collection reads with $select:

GET https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName,passwordCredentials,keyCredentials&$top=999
GET https://graph.microsoft.com/v1.0/servicePrincipals?$select=id,appId,displayName,servicePrincipalType,passwordCredentials,keyCredentials&$top=100

Follow @odata.nextLink until a response comes back without one, and filter the dates on your side. Page size matters here: applications allow 999 per page, service principals only 100, and Graph throttles requests that select keyCredentials to 150 per minute per tenant.

With the Microsoft Graph PowerShell SDK, -All follows the links for you:

# Requires: Install-Module Microsoft.Graph.Applications -Scope CurrentUser
param([int]$Days = 30)

Connect-MgGraph -Scopes 'Application.Read.All' -NoWelcome

$now    = [datetime]::UtcNow          # Graph returns credential dates in UTC
$cutoff = $now.AddDays($Days)
$select = 'id', 'appId', 'displayName', 'passwordCredentials', 'keyCredentials'

function Find-Expiring([string]$Kind, $Objects) {
  foreach ($o in $Objects) {
    $creds = @(
      foreach ($c in $o.PasswordCredentials) { @{ Type = 'Secret'; Cred = $c } }
      foreach ($c in $o.KeyCredentials) { @{ Type = "Cert ($($c.Usage))"; Cred = $c } }
    )
    foreach ($x in $creds) {
      $end = $x.Cred.EndDateTime
      if ($null -ne $end -and $end -le $cutoff) {   # includes already expired
        [pscustomobject]@{
          Kind     = $Kind
          Name     = $o.DisplayName
          AppId    = $o.AppId
          Type     = $x.Type
          Label    = $x.Cred.DisplayName
          Expires  = $end.ToString('yyyy-MM-dd')
          DaysLeft = [int][math]::Floor(($end - $now).TotalDays)
        }
      }
    }
  }
}

$apps = Get-MgApplication -All -PageSize 999 -Property $select
$sps  = Get-MgServicePrincipal -All -Property ($select + 'servicePrincipalType') |
  Where-Object ServicePrincipalType -ne 'ManagedIdentity'   # Azure rotates these

$report = @(
  Find-Expiring 'App registration' $apps
  Find-Expiring 'Service principal' $sps
)
$report | Sort-Object DaysLeft | Format-Table -AutoSize

Two details are deliberate. The SDK returns credential dates in UTC, so the comparison uses UtcNow, not local time. And already-expired credentials stay in the report: on an app that still exists, an expired secret is either an outage in progress or dead weight, and both need a decision from whoever owns the app. Add owners (Get-MgApplicationOwner) before the report goes anywhere; a row without a name is a row nobody fixes.

Microsoft publishes a sample script for this on Learn, but it covers app registrations only, so it never sees a SAML certificate.

Expiry is the symptom; long life is the smell

The outage is the visible cost. Microsoft’s own workload identity documentation names both: secrets and certificates “pose a security risk and have to be stored securely and rotated regularly,” and you “run the risk of service downtime if the credentials expire.”

The less visible cost is lifetime. The admin center caps a new client secret at 24 months, Microsoft recommends less than 12, and its guidance says client secrets shouldn’t be used in production at all. So run the same loop a second time with a different test — ($end - $x.Cred.StartDateTime).TotalDays -gt 365 on secrets — and you get the list that matters for security: every secret valid for longer than Microsoft recommends. That is also how long a copy left in a config file or a chat history stays useful to whoever holds it.

And if a secret expired months ago and nobody noticed, treat that as evidence nothing needs it: delete it instead of renewing it.

The better end state is fewer secrets

Rotation keeps the lights on; removal shrinks the problem.

  1. Managed identities for workloads running in Azure. There are no credentials to store or rotate; Microsoft points out they aren’t even accessible to you.
  2. Workload identity federation for GitHub Actions, Kubernetes, AWS, Google Cloud and other platforms that issue an OIDC token. The app registration trusts the external identity provider’s tokens, so nothing secret is stored, and a federated identity credential has no expiry date to track.
  3. Certificates over secrets for what remains, with short lifetimes, a named owner, and rotation done by automation rather than a calendar reminder.
  4. App management policies to make it stick: passwordLifetime caps how long a new secret can live, and passwordAddition blocks new secrets outright. They require a premium Entra license.

Automating the report

A script you remember to run is a start; a report that arrives on its own is a control. Our open-source Entra Credential Monitor is one way to get there: an MIT-licensed Node.js command-line tool that scans app registrations and service principals for expired and soon-expiring client secrets and certificates, across one tenant or many, and emails a compact HTML and plain-text report. It runs from GitHub Actions, cron or any scheduler that can provide environment variables, uses read-only Graph application permissions, and watches the expiry of its own credential too.

Expiry tells you when an app will break; its Graph permissions tell you what a leaked copy of its secret would cost. A Cloud Security Review ranks that second question alongside the rest of your exposure, and hands back the fixes as code in your repository — not as a list.

Tell us what’s going on