An app registration in a tenant I reviewed recently had no configured permissions in its manifest. The service principal behind it had been granted Directory.ReadWrite.All anyway, eight months earlier. Nobody on the operations team knew. The grant did not show up in any of the dashboards they checked.
That gap, configured permissions versus granted permissions, is where most overprivileged apps in Entra ID live. The manifest tells you what the developer asked for. The service principal tells you what someone in your tenant actually approved. They are not the same list, and almost no tooling shows them side by side.
Configured versus granted, briefly
An App Registration is the application object. It has a RequiredResourceAccess block listing the permissions the application asks for. This is the manifest, and it is what most reviewers look at.
A Service Principal is the instance of that application in your tenant. It receives actual grants in two places:
- AppRoleAssignments hold granted application permissions (the ones that look like
Mail.ReadorUser.Read.Allin admin consent dialogs). - OAuth2PermissionGrants hold granted delegated scopes (the ones that ride on a user sign-in).
These three lists drift in real tenants. A tenant admin can grant permissions the app never asked for. The manifest can list permissions nobody ever consented to. Both gaps tell you something different about your governance, and you cannot see either of them without reading all three lists at once.
Why this matters
Overprivileged app registrations that nobody is watching are a well known entry vector. Microsoft’s own post-mortem on the Midnight Blizzard intrusion of their corporate tenant (January 2024) describes a legacy OAuth application with elevated access to mailboxes as one of the components the attacker leveraged. That same shape, an old app from a project that wrapped up years ago, still holding broad grants, still credentialed, still nobody’s responsibility, exists in almost every tenant I review.
The script does not magically fix this. It puts the data on a single page so you can act on it.
What the script collects
For every App Registration in the tenant:
- Application metadata (DisplayName, AppId, PublisherDomain, VerifiedPublisher)
- The configured permissions block (RequiredResourceAccess), split into application permissions and delegated scopes, with permission GUIDs resolved to readable names
- AppRoleAssignments on the service principal (granted application permissions)
- OAuth2PermissionGrants for the service principal (granted delegated scopes)
- Owners, resolved to UPN, group, or another service principal
- Secrets and certificates, with the next expiry date in UTC and a flag if it falls within a window you choose (default 30 days)
The GUID-to-name resolution is the part that matters more than it sounds. A row that says Microsoft Graph / RoleManagement.ReadWrite.Directory is something you can discuss in a governance meeting. A row with two raw GUIDs is something you paste into a notepad and forget.
Two CSVs you actually use
AppRegistrations_Summary.csv is one row per app. The columns I open first:
ConfiguredApplicationPermsversusGrantedApplicationPermsConfiguredDelegatedScopesversusGrantedDelegatedScopesOwnersandOwnersCountSecretExpiringWithinDaysandCertExpiringWithinDays
AppRegistrations_Permissions.csv is one row per app per permission, with a PermissionType column whose values are ConfiguredApplication, ConfiguredDelegated, GrantedApplication, or GrantedDelegated. This is the long format. Drop it into a pivot table and you can answer “which apps have Mail.ReadWrite.All granted, regardless of whether their manifest asks for it” in two clicks.
Typical usage
# Default scan, output in current directory
./Export-EntraAppRegistrations.ps1
# Custom output directory, flag credentials expiring within 90 days
./Export-EntraAppRegistrations.ps1 -OutputDir ~/Reports/Tenant -ExpiringDays 90
# Specific tenant (when you switch between customer tenants on the same machine)
./Export-EntraAppRegistrations.ps1 -TenantId contoso.onmicrosoft.com
On a tenant with 250 app registrations the full run takes about three minutes. The dominant cost is the per-app calls to Get-MgServicePrincipalAppRoleAssignment and Get-MgOauth2PermissionGrant, which the Graph SDK does not batch.
What I look for first
Three pivots cover most of the value:
- Apps where the configured list is empty but the granted list is not. Someone consented to permissions the app never declared. Investigate the grant history and revoke or re-scope.
- Apps where the configured list contains broad write scopes (
Mail.ReadWrite.All,Files.ReadWrite.All,Directory.ReadWrite.All) and the secret or cert expires within 30 days. Either re-credential and tighten the manifest, or retire the app. The expiry is a forcing function. - Apps with
OwnersCount = 0. No-owner apps are often legacy from projects that ended and lost their human. Bring them under governance before they become tomorrow’s incident report.
Doing this once a quarter, alongside access reviews on the human accounts, is the cheapest meaningful step in app governance you can take.
Where to get it
The script lives on GitHub as part of my EntraGate work.
Source and latest version: maskovli/entragate, Export-EntraAppRegistrations.ps1
Pull requests welcome. If you find a tenant shape that breaks the SP lookup (multi-region service principals, federated apps with unusual resource IDs), open an issue.