Microsoft Entra ID PowerShell script Report only

Get-EntraAppCredentialExpiry.ps1

Reports app registration client secrets and certificates that are expired or expiring soon.

Overview#

Lists every app registration (GET /applications with $select=passwordCredentials,keyCredentials) through Microsoft Graph and returns one row per credential with its type, name, key id, validity window, remaining days and a status of Expired, ExpiringSoon (within -DaysUntilExpiry) or Valid. With -IncludeOwners the owners of each affected app are added (GET /applications/{id}/owners) so the report can be routed to the right team. By default only Expired and ExpiringSoon credentials are exported; -IncludeValid adds the healthy ones.

Safety: Report only — makes no changes to your tenant. Run Get-Help .\Get-EntraAppCredentialExpiry.ps1 -Full for the complete help text.

Parameters#

ParameterWhat it does
-DaysUntilExpiryCredentials expiring within this many days are reported as ExpiringSoon. Default 30.
-IncludeValidAlso exports credentials that are valid for longer than -DaysUntilExpiry.
-IncludeOwnersAdds an Owners column (UPN or display name, joined with ';'). One extra Graph call per reported application and the additional User.Read.All scope.
-OutputPathPath of the CSV report. Defaults to .\Reports\EntraAppCredentialExpiry_yyyyMMdd-HHmm.csv.
-PassThruAlso emits the report objects to the pipeline.

Examples#

PowerShell
PS> .\Get-EntraAppCredentialExpiry.ps1

Exports all secrets and certificates that are expired or expire within 30 days.

PowerShell
PS> .\Get-EntraAppCredentialExpiry.ps1 -DaysUntilExpiry 60 -IncludeOwners -OutputPath C:\Temp\AppCredentials.csv -Verbose

Uses a 60-day window, adds the owners of each application and saves the report to the given CSV.

PowerShell
PS> .\Get-EntraAppCredentialExpiry.ps1 -IncludeValid -PassThru | Sort-Object -Property DaysRemaining | Select-Object -First 10

Includes healthy credentials and shows the ten that expire next.

Permissions, modules and notes#

Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication Permissions : Application.Read.All (delegated); User.Read.All is added with -IncludeOwners so owner names can be read. Any user with the Application Administrator, Cloud Application Administrator, Global Reader or Security Reader role can run it. Notes : Only app registrations (application objects) are covered; credentials stored on enterprise apps (service principals) are not included. Secret values are never returned by Graph, only metadata. Owners can be users or service principals; service principals are listed by display name.

Full source#

PowerShell · Get-EntraAppCredentialExpiry.ps1
<#
.SYNOPSIS
    Reports app registration client secrets and certificates that are expired or expiring soon.
.DESCRIPTION
    Lists every app registration (GET /applications with $select=passwordCredentials,keyCredentials) through
    Microsoft Graph and returns one row per credential with its type, name, key id, validity window, remaining
    days and a status of Expired, ExpiringSoon (within -DaysUntilExpiry) or Valid. With -IncludeOwners the owners
    of each affected app are added (GET /applications/{id}/owners) so the report can be routed to the right team.
    By default only Expired and ExpiringSoon credentials are exported; -IncludeValid adds the healthy ones.
.PARAMETER DaysUntilExpiry
    Credentials expiring within this many days are reported as ExpiringSoon. Default 30.
.PARAMETER IncludeValid
    Also exports credentials that are valid for longer than -DaysUntilExpiry.
.PARAMETER IncludeOwners
    Adds an Owners column (UPN or display name, joined with ';'). One extra Graph call per reported application and
    the additional User.Read.All scope.
.PARAMETER OutputPath
    Path of the CSV report. Defaults to .\Reports\EntraAppCredentialExpiry_yyyyMMdd-HHmm.csv.
.PARAMETER PassThru
    Also emits the report objects to the pipeline.
.EXAMPLE
    PS> .\Get-EntraAppCredentialExpiry.ps1
    Exports all secrets and certificates that are expired or expire within 30 days.
.EXAMPLE
    PS> .\Get-EntraAppCredentialExpiry.ps1 -DaysUntilExpiry 60 -IncludeOwners -OutputPath C:\Temp\AppCredentials.csv -Verbose
    Uses a 60-day window, adds the owners of each application and saves the report to the given CSV.
.EXAMPLE
    PS> .\Get-EntraAppCredentialExpiry.ps1 -IncludeValid -PassThru | Sort-Object -Property DaysRemaining | Select-Object -First 10
    Includes healthy credentials and shows the ten that expire next.
.NOTES
    Author      : Omer Eltayeb
    Blog        : https://www.oeltayeb.com
    GitHub      : https://github.com/omer-eltayeb
    Version     : 1.0.0
    Requires    : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication
    Permissions : Application.Read.All (delegated); User.Read.All is added with -IncludeOwners so owner names can be
                  read. Any user with the Application Administrator, Cloud Application Administrator, Global Reader
                  or Security Reader role can run it.
    Notes       : Only app registrations (application objects) are covered; credentials stored on enterprise apps
                  (service principals) are not included. Secret values are never returned by Graph, only metadata.
                  Owners can be users or service principals; service principals are listed by display name.
.LINK
    https://learn.microsoft.com/graph/api/application-list
.LINK
    https://learn.microsoft.com/graph/api/application-list-owners
#>
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateRange(1, 3650)]
    [int]$DaysUntilExpiry = 30,

    [Parameter()]
    [switch]$IncludeValid,

    [Parameter()]
    [switch]$IncludeOwners,

    [Parameter()]
    [string]$OutputPath,

    [Parameter()]
    [switch]$PassThru
)

$ErrorActionPreference = 'Stop'

#region Helpers
function Connect-GraphIfNeeded {
    <# Connects to Microsoft Graph only when there is no usable session for the required scopes. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Scopes
    )
    $context = Get-MgContext
    $missingScopes = @()
    if ($null -ne $context) {
        $missingScopes = @($Scopes | Where-Object { $context.Scopes -notcontains $_ })
    }
    if ($null -eq $context -or $missingScopes.Count -gt 0) {
        Write-Verbose "Connecting to Microsoft Graph with scopes: $($Scopes -join ', ')"
        Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop | Out-Null
    }
    else {
        Write-Verbose "Reusing existing Microsoft Graph session for $($context.Account)."
    }
}

function Invoke-GraphPaged {
    <# GET helper that follows @odata.nextLink and returns every item in 'value'. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter()]
        [hashtable]$Headers
    )
    $results = New-Object -TypeName System.Collections.Generic.List[object]
    $nextLink = $Uri
    while (-not [string]::IsNullOrEmpty($nextLink)) {
        $requestParams = @{ Method = 'GET'; Uri = $nextLink; OutputType = 'PSObject'; ErrorAction = 'Stop' }
        if ($null -ne $Headers) { $requestParams['Headers'] = $Headers }
        $response = Invoke-MgGraphRequest @requestParams
        if ($null -ne $response.PSObject.Properties['value']) {
            foreach ($item in $response.value) { $results.Add($item) }
        }
        elseif ($null -ne $response) {
            $results.Add($response)
        }
        $nextLink = $response.'@odata.nextLink'
    }
    return $results
}

function ConvertTo-UtcDateTime {
    <# Normalises a Graph date value (ISO 8601 string or [datetime]) to a UTC [datetime]; returns $null when empty. #>
    param(
        [Parameter()]
        [object]$Value
    )
    if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { return $null }
    if ($Value -is [datetime]) { return $Value.ToUniversalTime() }
    $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal
    return [datetime]::Parse([string]$Value, [System.Globalization.CultureInfo]::InvariantCulture, $styles)
}

function Get-ApplicationOwners {
    <# Returns the owners of an application as 'upn;upn' (display name for non-user owners); empty when there are none. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ApplicationObjectId
    )
    $uri = 'https://graph.microsoft.com/v1.0/applications/{0}/owners?$select=id,displayName,userPrincipalName' -f $ApplicationObjectId
    $names = @()
    foreach ($owner in (Invoke-GraphPaged -Uri $uri)) {
        if (-not [string]::IsNullOrEmpty($owner.userPrincipalName)) { $names += $owner.userPrincipalName }
        elseif (-not [string]::IsNullOrEmpty($owner.displayName)) { $names += $owner.displayName }
    }
    Start-Sleep -Milliseconds 200
    return ($names -join ';')
}
#endregion Helpers

#region Main
$requiredScopes = @('Application.Read.All')
# Without a user-read scope Graph returns owner objects with only their id (limited information for inaccessible objects).
if ($IncludeOwners) { $requiredScopes += 'User.Read.All' }

if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    $reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
    $OutputPath = Join-Path -Path $reportFolder -ChildPath ('EntraAppCredentialExpiry_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
    New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}

try {
    Connect-GraphIfNeeded -Scopes $requiredScopes
}
catch {
    throw "Unable to connect to Microsoft Graph: $($_.Exception.Message)"
}

$uri = 'https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName,createdDateTime,passwordCredentials,keyCredentials,signInAudience&$top=999'
Write-Verbose 'Retrieving app registrations with their credentials.'
try {
    $applications = Invoke-GraphPaged -Uri $uri
}
catch {
    throw "Failed to list app registrations: $($_.Exception.Message)"
}
Write-Verbose "Evaluating credentials of $($applications.Count) app registrations."

$now = [datetime]::UtcNow
$rows = New-Object -TypeName System.Collections.Generic.List[object]
$appsWithoutCredentials = 0
$processed = 0
foreach ($application in $applications) {
    $processed++
    if ($processed % 50 -eq 0) {
        Write-Progress -Activity 'Evaluating application credentials' -Status "$processed of $($applications.Count)" -PercentComplete (($processed / $applications.Count) * 100)
    }
    $credentials = @()
    foreach ($secret in @($application.passwordCredentials)) {
        if ($null -ne $secret) { $credentials += [PSCustomObject]@{ Type = 'Secret'; Source = $secret } }
    }
    foreach ($certificate in @($application.keyCredentials)) {
        if ($null -ne $certificate) { $credentials += [PSCustomObject]@{ Type = 'Certificate'; Source = $certificate } }
    }
    if ($credentials.Count -eq 0) {
        $appsWithoutCredentials++
        continue
    }

    foreach ($credential in $credentials) {
        $source = $credential.Source
        $start = ConvertTo-UtcDateTime -Value $source.startDateTime
        $end = ConvertTo-UtcDateTime -Value $source.endDateTime
        $daysRemaining = $null
        $status = 'Valid'
        if ($null -ne $end) {
            # Floor keeps "expires later today" at 0 days and anything already past as a negative number.
            $daysRemaining = [int][math]::Floor(($end - $now).TotalDays)
            if ($daysRemaining -lt 0) { $status = 'Expired' }
            elseif ($daysRemaining -le $DaysUntilExpiry) { $status = 'ExpiringSoon' }
        }
        if ($status -eq 'Valid' -and -not $IncludeValid) { continue }

        $credentialName = $source.displayName
        if ([string]::IsNullOrEmpty($credentialName) -and -not [string]::IsNullOrEmpty($source.hint)) { $credentialName = 'hint: {0}...' -f $source.hint }

        $rows.Add([PSCustomObject]@{
            AppDisplayName     = $application.displayName
            AppId              = $application.appId
            ObjectId           = $application.id
            SignInAudience     = $application.signInAudience
            CredentialType     = $credential.Type
            CredentialName     = $credentialName
            KeyId              = $source.keyId
            StartDateTime      = $start
            EndDateTime        = $end
            DaysRemaining      = $daysRemaining
            Status             = $status
            Owners             = $null
            AppCreatedDateTime = ConvertTo-UtcDateTime -Value $application.createdDateTime
        })
    }
}
Write-Progress -Activity 'Evaluating application credentials' -Completed

$appsWithoutOwners = @()
if ($IncludeOwners -and $rows.Count -gt 0) {
    # Owners are looked up once per application and copied to every credential row of that app.
    $ownerCache = @{}
    $appIds = @($rows | Select-Object -ExpandProperty ObjectId -Unique)
    $processed = 0
    foreach ($objectId in $appIds) {
        $processed++
        Write-Progress -Activity 'Resolving application owners' -Status "$processed of $($appIds.Count)" -PercentComplete (($processed / $appIds.Count) * 100)
        try {
            $ownerCache[$objectId] = Get-ApplicationOwners -ApplicationObjectId $objectId
        }
        catch {
            $ownerCache[$objectId] = $null
            Write-Warning ('Owners of application {0} could not be read: {1}' -f $objectId, $_.Exception.Message)
        }
    }
    Write-Progress -Activity 'Resolving application owners' -Completed
    foreach ($row in $rows) {
        $row.Owners = $ownerCache[$row.ObjectId]
        if ([string]::IsNullOrEmpty($row.Owners) -and $appsWithoutOwners -notcontains $row.AppDisplayName) { $appsWithoutOwners += $row.AppDisplayName }
    }
}

$sortedRows = @($rows | Sort-Object -Property DaysRemaining, AppDisplayName)
if ($sortedRows.Count -gt 0) {
    $sortedRows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
    Write-Warning ('No credentials are expired or expiring within {0} days; no CSV was written.' -f $DaysUntilExpiry)
}

$expiredCount = @($sortedRows | Where-Object { $_.Status -eq 'Expired' }).Count
$expiringCount = @($sortedRows | Where-Object { $_.Status -eq 'ExpiringSoon' }).Count
$validCount = @($sortedRows | Where-Object { $_.Status -eq 'Valid' }).Count
Write-Host ''
Write-Host 'App credential expiry summary' -ForegroundColor Cyan
Write-Host ('  App registrations scanned : {0} ({1} without any credential)' -f $applications.Count, $appsWithoutCredentials)
Write-Host ('  Expired                   : {0}' -f $expiredCount) -ForegroundColor Red
Write-Host ('  Expiring within {0,3} days  : {1}' -f $DaysUntilExpiry, $expiringCount) -ForegroundColor Yellow
if ($IncludeValid) { Write-Host ('  Valid                     : {0}' -f $validCount) -ForegroundColor Green }
Write-Host ('  Rows exported             : {0} -> {1}' -f $sortedRows.Count, $OutputPath)

if ($appsWithoutOwners.Count -gt 0) {
    Write-Warning ('{0} application(s) in the report have no owner, so nobody will be notified about the renewal: {1}' -f $appsWithoutOwners.Count, ($appsWithoutOwners -join ', '))
}

if ($PassThru) {
    $sortedRows
}
#endregion Main

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.