Type to search 66 articles.

    Practical engineering guidance

    The client secret nobody renewed: auditing Entra ID application credentials

    Application secrets expire quietly and fail loudly, usually out of hours. A read-only script that lists every credential in the tenant with its expiry, owner and blast radius.

    Series: Microsoft Entra ID

    • Microsoft Entra ID
    • Microsoft Graph
    • PowerShell
    • Troubleshooting

    An integration that has worked for two years stops working at 02:00. Nothing was deployed, nothing was changed, and the error is a 401 from a service nobody has thought about since it was built. The cause is almost always the same: a client secret reached its expiry date.

    This failure is entirely predictable and almost never predicted, because the expiry date lives inside an application registration that belongs to a project which finished long ago.

    The problem: credentials expire on a schedule nobody reads

    An Entra ID application authenticates with one of three things:

    • a client secret — a string with an expiry date, at most 24 months by default
    • a certificate — a key credential, also with an expiry date
    • a federated identity credential — a trust relationship with an external issuer, which has no expiry at all

    The first two fail closed on a date chosen, usually arbitrarily, by whoever created them. The third is the only one that cannot expire, which is the strongest argument for using it.

    There is a second-order problem. Entra ID does send expiry notifications for SAML signing certificates used by enterprise single sign-on — 60, 30 and 7 days ahead, to the addresses configured on the application. It does not send an equivalent email for the client secrets and certificates on an app registration. Those surface through the Recommendations feature in the Entra admin center, which flags credentials expiring within 30 days, and which nobody reads at 02:00 either.

    So the estate needs an inventory, on a schedule, in a form a human will actually look at.

    What to collect

    For every application registration and every service principal:

    Field Why it matters
    Display name and application ID Identifies the thing that will break
    Credential type Secret and certificate fail the same way; federated credentials do not expire
    Credential display name Often the only clue about purpose
    End date The failure date
    Days remaining The triage order
    Owners Who to contact before it fails

    Owners matter more than they look. An expiring credential with no owner is a second finding, not a detail: it means the renewal will be done by whoever is on call, without knowing what the application does.

    The mechanism, briefly

    Credential expiry lives in two collection properties on both the application and servicePrincipal objects in Microsoft Graph:

    • passwordCredentials — client secrets, each with displayName, keyId, startDateTime, endDateTime and hint (the first three characters, which is all Graph will ever return of the secret itself)
    • keyCredentials — certificates, with the same date fields plus type and usage

    The secret value is returned exactly once, at creation. Nothing you can run recovers it later, which is why “renewing” a secret always means issuing a new one and updating the consumer.

    One documented trap: the key property of a keyCredential — the raw certificate bytes — is only returned when you request a single object with an explicit $select. In a collection query it is always null. That is fine here, because expiry dates come back in the collection, but it explains why a script that tries to fingerprint certificates from a list comes back empty-handed.

    The script

    Read-only. It reads application and service principal objects and writes a CSV. It makes no changes, and the permission it asks for cannot make changes.

    <#
    .SYNOPSIS
        Reports Entra ID application and service principal credentials by expiry date.
    
    .DESCRIPTION
        Read-only. Lists client secrets and certificates on application registrations and,
        optionally, service principals, with days remaining and (optionally) owners.
        Makes no changes to the tenant.
    
        Requires an existing Microsoft Graph connection with Application.Read.All, which is
        read-only. Adding or removing credentials would require Application.ReadWrite.All,
        which this script deliberately does not ask for:
    
            Connect-MgGraph -Scopes 'Application.Read.All','User.Read.All'
    
    .PARAMETER Days
        Report credentials expiring within this many days. Already-expired credentials are
        always included. Default 60.
    
    .PARAMETER ReportPath
        CSV output path. The folder must exist.
    
    .PARAMETER IncludeServicePrincipals
        Also inspect service principals. Enterprise applications and some first-party
        integrations hold their credentials here rather than on an app registration.
    
    .PARAMETER IncludeOwners
        Resolve owners. Costs one Graph call per application with a reportable credential,
        so it is off by default.
    
    .EXAMPLE
        .\Get-EntraAppCredentialExpiry.ps1 -Days 90 -ReportPath C:\Reports\credentials.csv
    
    .EXAMPLE
        .\Get-EntraAppCredentialExpiry.ps1 -IncludeServicePrincipals -IncludeOwners -Verbose
    
    .NOTES
        Read-only: this script makes no changes.
        Output names applications and owners. Treat the CSV as internal information.
        A credential with no owner is a finding in its own right, not a missing field.
    #>
    
    #requires -Version 7.0
    #requires -Modules Microsoft.Graph.Applications
    
    [CmdletBinding()]
    param(
        [ValidateRange(1, 730)]
        [int]$Days = 60,
    
        [ValidateScript({ Test-Path -Path (Split-Path -Path $_ -Parent) -PathType Container })]
        [string]$ReportPath = (Join-Path $env:TEMP 'entra-credential-expiry.csv'),
    
        [switch]$IncludeServicePrincipals,
    
        [switch]$IncludeOwners
    )
    
    $ErrorActionPreference = 'Stop'
    
    $context = Get-MgContext
    if ($null -eq $context) {
        throw "Not connected. Run: Connect-MgGraph -Scopes 'Application.Read.All','User.Read.All'"
    }
    Write-Verbose "Tenant $($context.TenantId), scopes: $($context.Scopes -join ', ')"
    
    $cutoff = (Get-Date).ToUniversalTime().AddDays($Days)
    $properties = 'id', 'displayName', 'appId', 'passwordCredentials', 'keyCredentials'
    $findings = [System.Collections.Generic.List[object]]::new()
    $failures = [System.Collections.Generic.List[object]]::new()
    
    # Owners are resolved once per object and cached: the same app can hold several credentials.
    $ownerCache = @{}
    function Resolve-Owner {
        param(
            [Parameter(Mandatory)][string]$ObjectId,
            [Parameter(Mandatory)][ValidateSet('Application', 'ServicePrincipal')][string]$ObjectType
        )
    
        if ($ownerCache.ContainsKey($ObjectId)) { return $ownerCache[$ObjectId] }
    
        $names = try {
            $owners = if ($ObjectType -eq 'Application') {
                Get-MgApplicationOwner -ApplicationId $ObjectId -All -ErrorAction Stop
            }
            else {
                Get-MgServicePrincipalOwner -ServicePrincipalId $ObjectId -All -ErrorAction Stop
            }
            # Owners are directory objects of mixed type; the display name lives in AdditionalProperties.
            @($owners | ForEach-Object {
                    $_.AdditionalProperties['userPrincipalName'] ??
                    $_.AdditionalProperties['displayName'] ??
                    $_.Id
                }) -join '; '
        }
        catch {
            Write-Warning "Owner lookup failed for $ObjectId - $($_.Exception.Message)"
            'lookup failed'
        }
    
        if ([string]::IsNullOrWhiteSpace($names)) { $names = 'none assigned' }
        $ownerCache[$ObjectId] = $names
        return $names
    }
    
    function Add-Finding {
        param(
            [Parameter(Mandatory)]$Object,
            [Parameter(Mandatory)][ValidateSet('Application', 'ServicePrincipal')][string]$ObjectType,
            [Parameter(Mandatory)][AllowEmptyCollection()]$Records,
            [Parameter(Mandatory)][ValidateSet('Secret', 'Certificate')][string]$Kind
        )
    
        foreach ($credential in $Records) {
            if ($null -eq $credential.EndDateTime) { continue }
    
            $end = [datetime]$credential.EndDateTime
            if ($end -gt $cutoff) { continue }
    
            $remaining = [math]::Floor(($end.ToUniversalTime() - (Get-Date).ToUniversalTime()).TotalDays)
    
            $findings.Add([pscustomobject]@{
                    ObjectType     = $ObjectType
                    DisplayName    = $Object.DisplayName
                    AppId          = $Object.AppId
                    ObjectId       = $Object.Id
                    CredentialType = $Kind
                    CredentialName = if ([string]::IsNullOrWhiteSpace($credential.DisplayName)) { '(unnamed)' } else { $credential.DisplayName }
                    EndDateUtc     = $end.ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
                    DaysRemaining  = $remaining
                    Status         = if ($remaining -lt 0) { 'Expired' } elseif ($remaining -le 14) { 'Critical' } else { 'Expiring' }
                    Owners         = if ($IncludeOwners) { Resolve-Owner -ObjectId $Object.Id -ObjectType $ObjectType } else { 'not queried' }
                })
        }
    }
    
    Write-Verbose 'Reading application registrations.'
    try {
        $applications = Get-MgApplication -All -Property $properties -ErrorAction Stop
    }
    catch {
        throw "Could not read applications. Confirm Application.Read.All is consented. $($_.Exception.Message)"
    }
    
    foreach ($application in $applications) {
        try {
            Add-Finding -Object $application -ObjectType 'Application' -Records $application.PasswordCredentials -Kind 'Secret'
            Add-Finding -Object $application -ObjectType 'Application' -Records $application.KeyCredentials -Kind 'Certificate'
        }
        catch {
            $failures.Add([pscustomobject]@{ Object = $application.DisplayName; Reason = $_.Exception.Message })
        }
    }
    
    if ($IncludeServicePrincipals) {
        Write-Verbose 'Reading service principals.'
        $servicePrincipals = Get-MgServicePrincipal -All -Property $properties -ErrorAction Stop
        foreach ($servicePrincipal in $servicePrincipals) {
            try {
                Add-Finding -Object $servicePrincipal -ObjectType 'ServicePrincipal' -Records $servicePrincipal.PasswordCredentials -Kind 'Secret'
                Add-Finding -Object $servicePrincipal -ObjectType 'ServicePrincipal' -Records $servicePrincipal.KeyCredentials -Kind 'Certificate'
            }
            catch {
                $failures.Add([pscustomobject]@{ Object = $servicePrincipal.DisplayName; Reason = $_.Exception.Message })
            }
        }
    }
    
    $sorted = $findings | Sort-Object DaysRemaining
    $sorted | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
    
    $expired = @($sorted | Where-Object Status -EQ 'Expired').Count
    $critical = @($sorted | Where-Object Status -EQ 'Critical').Count
    Write-Verbose "Wrote $ReportPath"
    if ($expired -gt 0) { Write-Warning "$expired credential(s) have already expired." }
    if ($critical -gt 0) { Write-Warning "$critical credential(s) expire within 14 days." }
    if ($failures.Count -gt 0) { Write-Warning "$($failures.Count) object(s) could not be read; see the warnings above." }
    
    $sorted

    Run it with -Verbose the first time. The tenant ID and consented scopes it prints are worth reading before you trust the output: a connection with fewer scopes than you expect silently returns fewer applications than exist.

    Reading the output

    Sort by DaysRemaining and work from the top. Three cases behave differently:

    Already expired, still present. Either the integration is broken and nobody has reported it, or the credential is unused. Both are worth knowing. Do not delete it to find out — an unused credential is harmless, and a deletion during business hours is not.

    Expiring inside two weeks. These need an owner and a change, in that order. Issue the new credential first, update the consumer, confirm it works, and only then remove the old one. Overlapping credentials are supported deliberately: an application can hold several at once, which is what makes a zero-downtime rotation possible.

    No owner. Treat this as its own remediation task. Assign an owner in Entra ID so the next run of this report can route the work without archaeology.

    The fix that stops it recurring

    For workloads running in Azure, on GitHub Actions, or anywhere that can present a token from a trusted issuer, replace the secret with a federated identity credential. The application then trusts an external issuer and subject rather than holding a secret at all, so there is nothing to expire and nothing to leak. An application can hold up to 20 of them, and each issuer and subject pair must be unique.

    Where a secret is unavoidable:

    • Set a deliberate lifetime and record the renewal in the same place as other scheduled work.
    • Name the credential after its consumer, not after the person who created it. (unnamed) in the report above is the single most common finding, and it is the one that costs the most time during an incident.
    • Assign at least two owners.
    • Run this report monthly, and read it.

    Verification and limits

    The Graph properties (passwordCredentials, keyCredentials, endDateTime, displayName, keyId, hint), the least-privilege permission Application.Read.All, the -All paging behaviour and -Property selection on Get-MgApplication and Get-MgServicePrincipal, the SAML certificate notification behaviour, and the federated identity credential limit of 20 per application were checked against current Microsoft documentation on 20 September 2026.

    The script was written for this article and statically analysed; it was not executed against a production tenant, and no output from a live tenant appears here. Test it in a non-production tenant first, as you would any script you did not write.

    Two limits worth stating. -IncludeOwners issues one Graph call per application with a reportable credential, which is slow on a large tenant and may be throttled; run it out of hours or narrow the window with -Days. And this report covers application and service principal credentials only — it does not cover the SAML signing certificates used for enterprise single sign-on, which have their own expiry notifications and their own renewal procedure.

    References