Somewhere in your tenant is an app registration a developer created for a "quick integration" three years ago. It got a client secret, it got consented to Directory.ReadWrite.All because that was easier than working out the exact scope, and it went into a pipeline that may or may not still run. The developer left. The secret didn't expire. Nobody has thought about it since.
App registrations are the service accounts nobody offboards. They're non-human identities that authenticate with a secret or certificate, skip every MFA and conditional-access control you built for people, and hold whatever permissions they were granted on the day someone was in a hurry. A user who leaves gets offboarded. An app that outlives its purpose just keeps its keys.
Why stale app registrations are a real risk
An over-permissioned app registration is a better target than a user account. It has no phone to prompt, no password to rotate, and its credential is often sitting in a config file, a pipeline variable, or a wiki page. If that secret leaks and the app holds Mail.Read as an application permission, the attacker reads every mailbox in the tenant, not one. Consent granted once, in 2022, is still consent today.
This is exactly the surface auditors have started asking about: inventory of application identities, least privilege, and removal of what's no longer used. BIO2 and NIS2 both expect you to know which non-human identities exist, what they can do, and that you retire the ones that went dark. "We review our app registrations" is a policy. The auditor wants the list: every app, when it was created, whether anything still calls it, and which of them hold tenant-wide write permissions they've clearly never needed. Let's build that list.
Requested is not granted
Here's the distinction most app-registration audits get wrong, and it's the whole point of this one.
An app registration has two separate permission stories. requiredResourceAccess on the application is what the app asks for, the list you see under "API permissions" in the portal. The grants on its service principal are what the app can actually do in this tenant. They are not the same thing. An app can request Directory.ReadWrite.All in its manifest and never have been consented, in which case that scary-looking permission is a request nobody answered, not a live risk.
Read the manifest alone and you flag apps that can't do anything. Read the grants alone and you miss what an app is designed to reach for. You want both columns side by side: what it wanted, and what it got. The script resolves each API permission GUID to a real name like Mail.Read and marks whether it's actually granted, so Directory.ReadWrite.All (requested, not granted) reads very differently from Directory.ReadWrite.All (granted).
When a name comes back and you're not sure how much it really grants, the Least Privilege tool does that lookup by hand: every Graph permission, delegated versus application, admin consent or not, with the tenant-wide ones flagged.
The one signal that costs a licence, and the ones that don't
"Is anything still using this app?" comes from servicePrincipalSignInActivities, a Graph report that rolls up the last time a service principal signed in across delegated and app-only flows. It's the honest answer to whether an app is a live integration or a museum piece.
It's also the expensive part. That report is a /beta preview API, it needs an Entra ID P1 or P2 licence and the AuditLog.Read.All permission, and it doesn't exist in US Gov or 21Vianet clouds. Miss any of those and the call fails.
So the script treats sign-in activity as optional. When the report runs, you get a real UsageState verdict per app. When it can't, the app doesn't lie to you: UsageState comes back as Unknown rather than a confident-but-false NeverSignedIn, and everything else, the creation dates, the ages, the requested-versus-granted permissions, still comes back on any tenant, licence or not. That last part matters, because two of the most damning findings, "created four years ago" and "holds Application.ReadWrite.All and has an expired secret," need no premium licence at all.
One honesty caveat baked into the verdict: the sign-in report isn't backfilled. NeverSignedIn means "no activity on record," not "provably never used." Read it as a strong hint to investigate, not a death certificate.
The script
Read-only. It pulls every app registration, resolves its permissions to names, checks what's actually granted, folds in sign-in activity when it can, and flags the apps holding high-privilege permissions. Nothing is changed. By default it skips Microsoft-published apps, because your own registrations are almost always what you're auditing.
<#
.SYNOPSIS
Inventories every app registration in an Entra ID tenant: when it was created,
whether it's still being used, and which API permissions it holds.
.DESCRIPTION
Builds one report row per app registration (/applications) with creation date and
age, last sign-in activity and a UsageState verdict, the API permissions the app
both requests and has actually been granted (GUIDs resolved to names like
'Mail.Read'), and credential expiry. Read-only: it changes nothing.
Requested vs. granted matters. requiredResourceAccess on the application is what the
app asks for; the grants on its service principal are what it can really do. An app
can request Directory.ReadWrite.All and never have been consented.
Sign-in activity comes from /beta/reports/servicePrincipalSignInActivities, which is
preview-only and needs Entra ID P1/P2. If that call fails the report still runs and
UsageState comes back as 'Unknown' rather than a misleading 'NeverSignedIn'.
.NOTES
Required Graph scopes : Application.Read.All, Directory.Read.All, AuditLog.Read.All
Required Entra role : Global Reader (or Reports Reader + Application Administrator)
Modules : Microsoft.Graph.Authentication, Microsoft.Graph.Applications
AuditLog.Read.All is only needed for sign-in activity; omit it with -SkipSignInActivity.
The sign-in report is a /beta preview API, not available in US Gov or 21Vianet clouds.
#>
[CmdletBinding()]
param(
[ValidateRange(1, 3650)]
[int] $UnusedAfterDays = 90,
[switch] $IncludeMicrosoftApps,
[string] $CsvPath,
[string] $PermissionCsvPath,
[switch] $SkipSignInActivity
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
#region Connection
$requiredScopes = @('Application.Read.All', 'Directory.Read.All')
if (-not $SkipSignInActivity) { $requiredScopes += 'AuditLog.Read.All' }
$context = Get-MgContext
if (-not $context) {
Write-Verbose "No Graph session; connecting with: $($requiredScopes -join ', ')"
Connect-MgGraph -Scopes $requiredScopes -NoWelcome
$context = Get-MgContext
}
else {
# Reuse the caller's session, but only if it can actually do the job. Directory.Read.All
# is a superset of Application.Read.All, so accept either.
$granted = @($context.Scopes)
$effective = $granted + $(if ($granted -contains 'Directory.Read.All') { 'Application.Read.All' })
$missing = $requiredScopes | Where-Object { $_ -notin $effective }
if ($missing) {
Write-Verbose "Existing session is missing: $($missing -join ', '). Reconnecting."
Connect-MgGraph -Scopes $requiredScopes -NoWelcome
$context = Get-MgContext
}
}
Write-Verbose "Tenant: $($context.TenantId) as $($context.Account)"
#endregion
#region Reference data
# Well-known permissions that make an app a real problem if it's compromised. Used only
# to flag rows for triage, a starting point for review, not a security verdict.
#
# Type matters, so there are two lists. Delegated permissions are bounded by the
# signed-in user; application permissions are not. Mail.Read delegated reads the user's
# own mailbox; Mail.Read as an application permission reads every mailbox in the tenant.
# Dangerous either way: admin-consent, tenant-wide write permissions.
$highPrivilegeAlways = @(
'Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All', 'Directory.ReadWrite.All',
'RoleManagement.ReadWrite.Directory', 'PrivilegedAccess.ReadWrite.AzureADGroup',
'User.ReadWrite.All', 'Group.ReadWrite.All', 'GroupMember.ReadWrite.All',
'Domain.ReadWrite.All', 'Policy.ReadWrite.ConditionalAccess', 'Sites.FullControl.All',
'DeviceManagementConfiguration.ReadWrite.All', 'DeviceManagementManagedDevices.ReadWrite.All'
)
# Only dangerous as application permissions, where "All" means the whole tenant rather
# than the one user who consented.
$highPrivilegeAsApplication = @(
'Mail.Read', 'Mail.ReadWrite', 'Mail.Send', 'MailboxSettings.ReadWrite',
'Files.Read.All', 'Files.ReadWrite.All', 'Sites.Read.All', 'Sites.ReadWrite.All',
'Calendars.ReadWrite', 'Contacts.ReadWrite'
)
function Test-HighPrivilegePermission {
param(
[Parameter(Mandatory)][AllowEmptyString()][string] $Name,
[Parameter(Mandatory)][string] $Type
)
if ($Name -in $highPrivilegeAlways) { return $true }
return $Type -eq 'Application' -and $Name -in $highPrivilegeAsApplication
}
# Resource service principals are shared across apps (nearly every app points at Microsoft
# Graph), so resolve each one once. Without this, a few hundred apps means thousands of calls.
$resourceSpCache = @{}
function Get-ResourceServicePrincipal {
param([Parameter(Mandatory)][string] $ResourceAppId)
if ($resourceSpCache.ContainsKey($ResourceAppId)) {
return $resourceSpCache[$ResourceAppId]
}
$sp = $null
try {
$sp = Get-MgServicePrincipal -Filter "appId eq '$ResourceAppId'" `
-Property 'id,appId,displayName,appRoles,oauth2PermissionScopes' `
-ErrorAction Stop | Select-Object -First 1
}
catch {
Write-Warning "Could not resolve resource app $ResourceAppId : $($_.Exception.Message)"
}
$resourceSpCache[$ResourceAppId] = $sp
return $sp
}
#endregion
#region Sign-in activity
# appId -> lastSignInDateTime. Keyed by appId because that's the only field that ties the
# report back to an application; the report's own id is an opaque encoding.
$signInByAppId = @{}
$signInDataAvailable = $false
if (-not $SkipSignInActivity) {
Write-Verbose 'Retrieving service principal sign-in activity (beta preview report)...'
try {
$uri = 'https://graph.microsoft.com/beta/reports/servicePrincipalSignInActivities'
while ($uri) {
$response = Invoke-MgGraphRequest -Method GET -Uri $uri -ErrorAction Stop
foreach ($entry in $response.value) {
# lastSignInActivity is the roll-up across delegated/app-only and
# client/resource flows: exactly the "is anything still calling this?"
# question. Absent when the SP has no recorded activity at all.
$last = $null
if ($entry.ContainsKey('lastSignInActivity') -and $entry.lastSignInActivity) {
$last = $entry.lastSignInActivity.lastSignInDateTime
}
if ($entry.appId) {
$signInByAppId[$entry.appId] = $last
}
}
$uri = if ($response.ContainsKey('@odata.nextLink')) { $response.'@odata.nextLink' } else { $null }
}
$signInDataAvailable = $true
Write-Verbose "Sign-in activity retrieved for $($signInByAppId.Count) service principals."
}
catch {
# Most likely: no Entra ID P1/P2, missing AuditLog.Read.All, or a sovereign cloud
# where the preview report doesn't exist. Not fatal: the rest of the report is useful.
Write-Warning "Sign-in activity unavailable, UsageState will be 'Unknown'. $($_.Exception.Message)"
Write-Warning 'This report requires Entra ID P1/P2 and AuditLog.Read.All.'
}
}
#endregion
#region Service principals for the tenant's own apps
# One pass to map appId -> SP. Needed both to know whether an app has an SP at all and to
# read what's actually been granted to it.
Write-Verbose 'Retrieving service principals...'
$spByAppId = @{}
Get-MgServicePrincipal -All -Property 'id,appId,displayName,accountEnabled' |
ForEach-Object { $spByAppId[$_.AppId] = $_ }
Write-Verbose "Found $($spByAppId.Count) service principals."
#endregion
#region Applications
Write-Verbose 'Retrieving app registrations...'
# Get-MgApplication doesn't return createdDateTime by default, so ask for it explicitly.
$applications = Get-MgApplication -All -Property @(
'id', 'appId', 'displayName', 'createdDateTime', 'signInAudience', 'publisherDomain',
'requiredResourceAccess', 'passwordCredentials', 'keyCredentials', 'notes', 'tags'
)
if (-not $IncludeMicrosoftApps) {
$before = $applications.Count
$applications = $applications | Where-Object {
$_.PublisherDomain -notmatch '(?i)^(microsoft\.com|sharepointonline\.com)$'
}
Write-Verbose "Excluded $($before - $applications.Count) Microsoft-published apps (use -IncludeMicrosoftApps to keep them)."
}
Write-Verbose "Processing $($applications.Count) app registrations..."
#endregion
$now = [DateTime]::UtcNow
$permissionRows = [System.Collections.Generic.List[object]]::new()
$results = [System.Collections.Generic.List[object]]::new()
$appIndex = 0
foreach ($app in $applications) {
$appIndex++
Write-Progress -Activity 'Inventorying app registrations' `
-Status "$appIndex/$($applications.Count): $($app.DisplayName)" `
-PercentComplete (($appIndex / [Math]::Max($applications.Count, 1)) * 100)
$sp = $spByAppId[$app.AppId]
#region Granted permissions (what the app can actually do)
# Only meaningful if an SP exists: consent is recorded against the SP, not the app.
$grantedAppRoleIds = @{} # appRoleId -> $true
$grantedScopes = @{} # scope name -> $true
if ($sp) {
try {
foreach ($assignment in Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All) {
$grantedAppRoleIds[$assignment.AppRoleId] = $true
}
}
catch {
Write-Warning "Could not read app role assignments for '$($app.DisplayName)': $($_.Exception.Message)"
}
try {
# Read via the SP's own navigation property rather than Get-MgOauth2PermissionGrant,
# which lives in Microsoft.Graph.Identity.SignIns: not worth a third module
# dependency for one call.
$grantUri = "https://graph.microsoft.com/v1.0/servicePrincipals/$($sp.Id)/oauth2PermissionGrants"
while ($grantUri) {
$grantResponse = Invoke-MgGraphRequest -Method GET -Uri $grantUri -ErrorAction Stop
foreach ($grant in $grantResponse.value) {
# scope is a space-delimited string; a grant exists per resource and per
# consent type (AllPrincipals = admin consent, Principal = a single user).
if (-not $grant.scope) { continue }
foreach ($scope in ($grant.scope -split '\s+' | Where-Object { $_ })) {
$grantedScopes[$scope] = $true
}
}
$grantUri = if ($grantResponse.ContainsKey('@odata.nextLink')) { $grantResponse.'@odata.nextLink' } else { $null }
}
}
catch {
Write-Warning "Could not read delegated grants for '$($app.DisplayName)': $($_.Exception.Message)"
}
}
#endregion
#region Requested permissions, resolved to names
$permissions = [System.Collections.Generic.List[object]]::new()
foreach ($required in @($app.RequiredResourceAccess)) {
$resourceSp = Get-ResourceServicePrincipal -ResourceAppId $required.ResourceAppId
$resourceName = if ($resourceSp) { $resourceSp.DisplayName } else { "Unknown ($($required.ResourceAppId))" }
foreach ($access in @($required.ResourceAccess)) {
# 'Role' = application permission (app-only, no user present).
# 'Scope' = delegated permission (acts on behalf of a signed-in user).
$isAppRole = $access.Type -eq 'Role'
$permissionName = $null
$isGranted = $false
if ($resourceSp) {
if ($isAppRole) {
$role = $resourceSp.AppRoles | Where-Object { $_.Id -eq $access.Id } | Select-Object -First 1
if ($role) { $permissionName = $role.Value }
$isGranted = $grantedAppRoleIds.ContainsKey($access.Id)
}
else {
$scope = $resourceSp.Oauth2PermissionScopes | Where-Object { $_.Id -eq $access.Id } | Select-Object -First 1
if ($scope) { $permissionName = $scope.Value }
if ($permissionName) { $isGranted = $grantedScopes.ContainsKey($permissionName) }
}
}
# Fall back to the GUID so an unresolvable permission is still visible rather
# than silently dropped from the report.
if (-not $permissionName) { $permissionName = "($($access.Id))" }
$permissions.Add([pscustomobject]@{
ResourceName = $resourceName
ResourceAppId = $required.ResourceAppId
PermissionName = $permissionName
PermissionType = if ($isAppRole) { 'Application' } else { 'Delegated' }
IsGranted = $isGranted
})
}
}
foreach ($permission in $permissions) {
$permissionRows.Add([pscustomobject]@{
AppDisplayName = $app.DisplayName
AppId = $app.AppId
ResourceName = $permission.ResourceName
PermissionName = $permission.PermissionName
PermissionType = $permission.PermissionType
IsGranted = $permission.IsGranted
})
}
#endregion
#region Usage verdict
$lastSignIn = $null
$daysSinceSignIn = $null
if ($signInByAppId.ContainsKey($app.AppId) -and $signInByAppId[$app.AppId]) {
$lastSignIn = [DateTime]$signInByAppId[$app.AppId]
$daysSinceSignIn = [Math]::Round(($now - $lastSignIn.ToUniversalTime()).TotalDays)
}
$usageState =
if (-not $sp) { 'NoServicePrincipal' } # can't sign in here at all
elseif (-not $signInDataAvailable) { 'Unknown' } # report didn't run
elseif ($null -eq $lastSignIn) { 'NeverSignedIn' } # no activity on record
elseif ($daysSinceSignIn -le $UnusedAfterDays) { 'Active' }
else { 'Stale' }
#endregion
#region Credentials
$credentials = @(@($app.PasswordCredentials) + @($app.KeyCredentials) | Where-Object { $_ })
$activeCredentials = @($credentials | Where-Object { $_.EndDateTime -and [DateTime]$_.EndDateTime -gt $now })
$nextExpiry = $activeCredentials | Sort-Object EndDateTime | Select-Object -First 1
#endregion
$createdDateTime = if ($app.CreatedDateTime) { [DateTime]$app.CreatedDateTime } else { $null }
$results.Add([pscustomobject]@{
DisplayName = $app.DisplayName
AppId = $app.AppId
ObjectId = $app.Id
CreatedDateTime = $createdDateTime
AgeInDays = if ($createdDateTime) { [Math]::Round(($now - $createdDateTime.ToUniversalTime()).TotalDays) } else { $null }
UsageState = $usageState
LastSignInDateTime = $lastSignIn
DaysSinceLastSignIn = $daysSinceSignIn
ServicePrincipalId = if ($sp) { $sp.Id } else { $null }
AccountEnabled = if ($sp) { $sp.AccountEnabled } else { $null }
SignInAudience = $app.SignInAudience
PublisherDomain = $app.PublisherDomain
PermissionCount = $permissions.Count
GrantedPermissionCount = @($permissions | Where-Object { $_.IsGranted }).Count
ApplicationPermissions = ($permissions | Where-Object { $_.PermissionType -eq 'Application' } | ForEach-Object { $_.PermissionName } | Sort-Object -Unique) -join '; '
DelegatedPermissions = ($permissions | Where-Object { $_.PermissionType -eq 'Delegated' } | ForEach-Object { $_.PermissionName } | Sort-Object -Unique) -join '; '
GrantedPermissions = ($permissions | Where-Object { $_.IsGranted } | ForEach-Object { $_.PermissionName } | Sort-Object -Unique) -join '; '
HasHighPrivilegePermission = [bool]@($permissions | Where-Object {
$_.IsGranted -and (Test-HighPrivilegePermission -Name $_.PermissionName -Type $_.PermissionType)
}).Count
SecretCount = @($app.PasswordCredentials).Where({ $_ }).Count
CertificateCount = @($app.KeyCredentials).Where({ $_ }).Count
HasValidCredential = $activeCredentials.Count -gt 0
NextCredentialExpiry = if ($nextExpiry) { [DateTime]$nextExpiry.EndDateTime } else { $null }
Permissions = $permissions # kept for pipeline use; dropped on CSV export
})
}
Write-Progress -Activity 'Inventorying app registrations' -Completed
#region Output
if ($CsvPath) {
# Permissions is a nested object collection and would export as a type name.
$results |
Select-Object -ExcludeProperty Permissions |
Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Verbose "Wrote app inventory to $CsvPath"
}
if ($PermissionCsvPath) {
$permissionRows | Export-Csv -Path $PermissionCsvPath -NoTypeInformation -Encoding UTF8
Write-Verbose "Wrote $($permissionRows.Count) permission rows to $PermissionCsvPath"
}
$summary = $results | Group-Object UsageState | Sort-Object Name
Write-Verbose "Summary: $(($summary | ForEach-Object { "$($_.Name)=$($_.Count)" }) -join ', ')"
$results
#endregion
Reading the result
The report is one object per app, so pipe it however you think. But the value is in a few columns read together.
| Column | What it's telling you |
|---|---|
UsageState |
Stale (no sign-in past your cutoff), NeverSignedIn (no activity on record), NoServicePrincipal (an app object with no SP, so it can't sign in here at all), or Unknown (the licensed report didn't run). Active is the only one you don't need to look at. |
HasHighPrivilegePermission |
The app holds a tenant-wide write scope, or a mailbox/file application permission, and it's actually granted. This is the triage flag, not a verdict. |
GrantedPermissions vs ApplicationPermissions |
What the app got versus what it asked for. A short granted list under a long requested list is an app that over-asked and was rightly reined in. The reverse is the one to worry about. |
HasValidCredential + NextCredentialExpiry |
A stale app with a live secret is a standing key to nowhere. A stale app with an expired credential is a strong second signal that nothing is using it. |
The single query that earns the whole script is the cleanup shortlist: apps that are stale but still dangerous.
# Stale apps that still hold high-privilege permissions. Start your review here.
.\EntraID-Discover-AppRegistrations.ps1 |
Where-Object { $_.UsageState -eq 'Stale' -and $_.HasHighPrivilegePermission } |
Sort-Object DaysSinceLastSignIn -Descending |
Format-Table DisplayName, DaysSinceLastSignIn, GrantedPermissions
Nothing calling it in months, and it can still rewrite your directory. That's the row you take to the app owner, if you can still find one.
For pivoting the other way, "show me every app that holds Mail.ReadWrite," export the long-format permission list and filter that:
.\EntraID-Discover-AppRegistrations.ps1 -PermissionCsvPath .\perms.csv
Import-Csv .\perms.csv | Where-Object { $_.PermissionName -eq 'Mail.ReadWrite' -and $_.IsGranted -eq 'True' }
No Entra Premium, or you only want the free signals? Run it with -SkipSignInActivity. You lose the UsageState verdict, but you keep every creation date, every requested-versus-granted permission, and every credential expiry, which is still enough to find the four-year-old app holding Application.ReadWrite.All. That split, which answers sit behind P1/P2 and which you can get for nothing, is mapped out per feature in Licensing.
From list to action
Don't mass-delete app registrations. Deleting an app that turns out to run payroll at 2am on the last day of the month is a bad afternoon, and the sign-in report isn't backfilled, so a fresh NeverSignedIn might just mean "no activity recorded yet."
Disable first, the same pattern that works for stale users. Set accountEnabled to false on the app's service principal (not the application object) and the app can't authenticate, instantly and reversibly. Give it a few weeks. If nothing breaks and nobody shouts, delete it. The export you saved is your worklist now and your audit trail later: this is the before, the disabled apps are the after, and the ones still standing are the ones you deliberately kept.
And before you disable, use the columns you have to ask the owner one real question: "this app was created in 2022, hasn't signed in for 300 days, and can read every mailbox in the tenant, do we still need it?" Most of the time the answer is a sheepish no. That last clause is the one that lands, so it pays to get it right: Least Privilege spells out what a given permission actually reaches, and flags the ones that reach the whole tenant.
Prove it, don't assume it
You almost certainly have more app registrations than people who remember creating them, and some of them can do far more than anything they're still used for. That's not a failure of process, it's just what happens when the easiest identity to create is also the one nobody's job it is to retire.
Run the inventory once and you'll have your shortlist by the end of the coffee. Run it on a schedule and you turn "we review our app registrations" from a sentence in a policy into a CSV you can hand an auditor. Least privilege isn't the day you consented carefully. It's the day you went back and checked.
Want this folded into a recurring review, or into a broader baseline assessment of your tenant's non-human identities? Let me know.