All posts

7 min readMicrosoft Entra · App Registrations · MSP · PowerShell

Building an Entra app registration inventory across customer tenants

How MSPs build an Entra app registration inventory across customer tenants: which fields to collect per app registration and service principal, how to read them with Graph PowerShell and REST, how to spot stale apps, and how to list expiring secrets and certificates per customer.

Read this post in German

An MSP with 30 customer tenants quickly ends up with several hundred app registrations. Most were not created by your team: backup vendors, ticketing connectors, a three-year-old script, one department's SSO. Without an inventory you notice an expiring secret when the integration stops. This post covers what to collect, how to read it with Microsoft Graph and how to do that across many tenants.

Why the inventory matters

Four problems show up in practically every tenant:

  • Orphaned apps. The creator left the company, the app has no owner, nobody knows whether it is still needed.
  • Unknown credentials. An app registration may carry several secrets and certificates at the same time. Which one is in use is written down nowhere.
  • Expiring secrets. For credentials on app registrations Microsoft sends no notification. What happens at expiry is covered in Entra app registration secrets expire.
  • SAML signing certificates. They live on the service principal of the enterprise application, not on the app registration. Anyone who checks only app registrations misses them. When the certificate expires, SSO stops.

Add the compliance questions from customers: which apps have access to our directory, with which permissions, who is responsible. An inventory answers that in minutes instead of days.

What to collect per app registration

From the application object in Microsoft Graph:

  • displayName, appId and id (the object ID)
  • createdDateTime: age of the registration
  • owners: a separate request, see below
  • signInAudience: AzureADMyOrg for single tenant, AzureADMultipleOrgs for multi-tenant
  • passwordCredentials with displayName, endDateTime and keyId
  • keyCredentials with type, usage and endDateTime
  • requiredResourceAccess: the APIs and permissions the app requests

Graph never returns the value of a secret, only metadata. The inventory therefore contains nothing confidential.

What to collect per service principal

Every enterprise application is a servicePrincipal. Relevant:

  • keyCredentials: this is where SAML signing certificates live. Entra stores them as a pair, AsymmetricX509Cert with usage Verify and X509CertAndPassword with usage Sign. Maximum lifetime is three years.
  • preferredSingleSignOnMode: saml, password, oidc or notSupported. Use it to filter the SSO apps.
  • appRoleAssignments: the application permissions actually granted through admin consent.

Reading it with Microsoft Graph PowerShell

For unattended runs use app-only authentication with a certificate. The certificate must be in the certificate store of the account that runs the script.

Connect-MgGraph -ClientId $clientId -TenantId $tenantId -CertificateThumbprint $thumbprint -NoWelcome

$apps = Get-MgApplication -All -Property Id, DisplayName, AppId, CreatedDateTime,
  SignInAudience, PasswordCredentials, KeyCredentials, RequiredResourceAccess

$inventory = foreach ($app in $apps) {
  $owners = (Get-MgApplicationOwner -ApplicationId $app.Id -All |
    ForEach-Object { $_.AdditionalProperties['userPrincipalName'] }) -join ';'

  $secrets = $app.PasswordCredentials | ForEach-Object { @{ Kind = 'Secret'; Cred = $_ } }
  $certs   = $app.KeyCredentials      | ForEach-Object { @{ Kind = 'Certificate'; Cred = $_ } }

  foreach ($c in @($secrets) + @($certs)) {
    [pscustomobject]@{
      App = $app.DisplayName; AppId = $app.AppId; Created = $app.CreatedDateTime
      Owners = $owners; Kind = $c.Kind; Credential = $c.Cred.DisplayName
      KeyId = $c.Cred.KeyId; Expires = $c.Cred.EndDateTime
    }
  }
}
$inventory | Sort-Object Expires

The same for service principals with SAML, limited to the signing certificate:

Get-MgServicePrincipal -All -Property Id, DisplayName, AppId, PreferredSingleSignOnMode, KeyCredentials |
  Where-Object { $_.PreferredSingleSignOnMode -eq 'saml' } |
  ForEach-Object {
    $sp = $_
    $sp.KeyCredentials | Where-Object { $_.Usage -eq 'Sign' } | ForEach-Object {
      [pscustomobject]@{ App = $sp.DisplayName; Certificate = $_.DisplayName; Expires = $_.EndDateTime }
    }
  }

Reading it with Graph REST

The same data over HTTP, one request per collection:

GET https://graph.microsoft.com/v1.0/applications?$select=id,displayName,appId,createdDateTime,signInAudience,passwordCredentials,keyCredentials,requiredResourceAccess&$top=999
GET https://graph.microsoft.com/v1.0/applications/{id}/owners
GET https://graph.microsoft.com/v1.0/servicePrincipals?$select=id,displayName,appId,preferredSingleSignOnMode,keyCredentials
GET https://graph.microsoft.com/v1.0/servicePrincipals/{id}/appRoleAssignments

/applications returns 100 objects per page by default, up to 999 with $top. /servicePrincipals returns 100 per page, no more. As long as the response contains @odata.nextLink, call that URL unchanged for the next page.

The plain list with $select needs neither ConsistencyLevel: eventual nor $count. Both are mandatory with $search, with not and ne, with $filter plus $orderby, and for queries like this:

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

Graph does not carry the header into the following pages automatically; set it on every call to the @odata.nextLink. And $select on keyCredentials is throttled to 150 requests per minute per tenant.

Across many customer tenants

One app registration in your own tenant, signInAudience set to AzureADMultipleOrgs, application permission Application.Read.All, certificate uploaded. Each customer grants admin consent once through this URL:

https://login.microsoftonline.com/{tenant-id}/adminconsent?client_id={client-id}

After that a service principal of your app exists in the customer tenant, and the same certificate authenticates against every tenant:

$tenants = Import-Csv .\tenants.csv   # columns: Name, TenantId
foreach ($t in $tenants) {
  Connect-MgGraph -ClientId $clientId -TenantId $t.TenantId -CertificateThumbprint $thumbprint -NoWelcome
  Get-TenantInventory |                # the queries above, wrapped in a function
    Add-Member -NotePropertyName Tenant -NotePropertyValue $t.Name -PassThru |
    Export-Csv ".\inventory\$($t.Name)-$(Get-Date -Format yyyy-MM-dd).csv" -NoTypeInformation
  Disconnect-MgGraph
}

Azure Lighthouse does not help here. It delegates Azure subscriptions and resource groups through Azure RBAC. Your engineers work in the context of the customer subscription without holding an account in the customer directory. That is exactly why the delegation stops short of Entra ID: /applications and /servicePrincipals are directory objects, not Azure resources. For the inventory you need the consent in the customer tenant.

Finding stale apps

Whether an app is still used is in the service principal sign-in activity report, beta endpoint only:

GET https://graph.microsoft.com/beta/reports/servicePrincipalSignInActivities

Each entry carries appId and lastSignInActivity.lastSignInDateTime, broken down into delegated and app-only flows. Prerequisites: the application permission AuditLog.Read.All and a Microsoft Entra ID P1 or P2 licence in the customer tenant. The PowerShell cmdlet is Get-MgBetaReportServicePrincipalSignInActivity from the Microsoft.Graph.Beta.Reports module. Last use per credential is under /beta/reports/appCredentialSignInActivities, also preview.

Without a P1 licence you are left with heuristics: no owner, older than two years, secrets expired for months and nobody complained. Such apps go on a list that the customer approves before you delete anything.

Output: one table per customer

One CSV per tenant and run, sorted by nearest expiry. These columns have proven useful:

TenantAppCredentialKindExpiresDaysOwnerLast sign-in
ContosoBackup connectorProd 2025Secret2026-10-0911none2026-09-27
ContosoHR portal SSOSAML SigningCertificate2026-11-3063j.mueller2026-09-26
FabrikamLegacy Synckey-1Secret2026-12-1578noneunknown

Rows without owner and without sign-in go to clarification, rows under 30 days go to rotation.

Schedule: weekly with deltas

One run per week is enough when the warning thresholds are at 90, 30 and 14 days. More important than the full dump is the delta against the last run: new app registrations, new credentials (matched on keyId), deleted apps, credentials that expired since the last run. The delta is a short list, the full dump is the archive for the next compliance question. Run it as an Azure Automation runbook or a scheduled task on a management server.

Hygiene actions from the inventory

  • Assign owners. At least one responsible person in the customer tenant, better two. The owners/$count eq 0 query above gives you the list.
  • Delete unused apps. After the customer approves, not before.
  • Remove expired credentials. They do nothing anymore but hide the active secret in the list.
  • Shorten lifetimes. Twelve months for secrets, certificates instead of secrets where the application supports them. The rotation rules are in the post on expiring secrets.
  • Maintain descriptions. A secret called key-1 cannot be traced at the next rotation.

How SecretExpiry helps

SecretExpiry covers the time-critical part of the inventory: client secrets and certificates across all customer tenants, synced continuously. The permission is the same Application.Read.All, granted once per customer through admin consent, read-only. Alerts arrive by e-mail per event or as a weekly digest, in a Teams or Slack channel through a webhook, or as a calendar subscription (ICS), with a dedicated recipient address per tenant and snooze for known cases. The dashboard shows an urgent list and a per-tenant view, and the CSV export replaces the weekly run. The trial is free for 14 days, after that the service costs from 10 € per tenant per month. Setup is described in the docs.

Continuous monitoring or periodic script

A weekly script delivers an inventory up to seven days old and depends on the account, certificate and server it runs on. The certificate of the inventory app expires too and belongs on the same list. Continuous monitoring syncs without a schedule you maintain, reports changes instead of states and needs no operation on your side. For compliance questions about permissions and owners the script remains the right tool, because it reads every field. For the expiry of secrets and certificates a notification that arrives on its own is more reliable than a file somebody has to open.