Cleaning up over-consented app permissions
By Emil Björk · Microsoft ecosystem consultant, Gothenburg
How to find the applications in your tenant holding more Graph permission than they use — delegated and application grants, who consented, and what the app actually calls — and how to reduce them without breaking integrations that only need a fraction of what they were given.
An app that asks for Mail.ReadWrite when it sends one notification a week; a reporting script granted Directory.ReadWrite.All because the developer could not find the read-only scope; a vendor integration a user consented to in 2022 that can read every file they can see. None of these are incidents. All of them are the reason a future incident will be worse than it needed to be, because when an app's credential leaks, the blast radius is the permission set, not the use. The service principal best practices guide explains the principle; this is the procedure for applying it to a tenant that already has hundreds of grants.
Two kinds of grant, two lists
Delegated permissions (OAuth2 permission grants) let an app act as a signed-in user, bounded by what the user can do. They are granted either by an admin for all users, or by individual users for themselves. Application permissions (app role assignments on the Microsoft Graph service principal) let an app act on its own, tenant-wide, with no user in the loop. Application permissions are the dangerous kind; a single Mail.Read application permission is every mailbox in the tenant.
Export both:
Connect-MgGraph -Scopes "Application.Read.All","DelegatedPermissionGrant.Read.All","Directory.Read.All"
# Delegated grants
Get-MgOauth2PermissionGrant -All | Select-Object ClientId, ConsentType, PrincipalId, ResourceId, Scope | Export-Csv .\delegated.csv -NoTypeInformation
# Application permissions on Microsoft Graph
$graph = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $graph.Id -All | Select-Object PrincipalDisplayName, PrincipalId, AppRoleId | Export-Csv .\application.csv -NoTypeInformation
Resolve AppRoleId to a permission name through $graph.AppRoles, and ClientId to a display name through Get-MgServicePrincipal. The Entra portal's per-app Permissions blade shows the same for one app at a time, which is fine for a review of one and useless for a review of three hundred.
If you have Defender for Cloud Apps with app governance, it does this correlation continuously, flags apps with high-privilege permissions that are unused (it watches the Graph calls the app actually makes), and lets you disable an app from the same screen. It is the tool built for this job; the manual route below is for tenants without it.
Step 1: rank by risk, not by count
Sort the application-permission list first. Any of these on a non-Microsoft app is worth a conversation this week:
Directory.ReadWrite.All,RoleManagement.ReadWrite.Directory,Application.ReadWrite.All,AppRoleAssignment.ReadWrite.All— these can escalate to Global Administrator.Mail.ReadWrite,Mail.Send,Mail.Read— all mailboxes.Files.ReadWrite.All,Sites.ReadWrite.All,Sites.FullControl.All— all content.User.ReadWrite.All,Group.ReadWrite.All— identity manipulation.Chat.Read.All,ChannelMessage.Read.All— Teams content.
Then the delegated list, looking for tenant-wide admin consents to the same scopes, and for user-consented grants (ConsentType = Principal) with Mail.*, Files.* or offline_access to apps from other tenants — the classic consent-phishing footprint.
Step 2: find out what the app actually does
This is the step that separates cleanup from vandalism. For each high-risk app:
- Ask the owner what the integration does, and read the vendor's documentation of required permissions. Vendors overstate; the minimum list is usually in a support article rather than the setup wizard.
- Look at the activity. Sign-in logs tell you that the app authenticates, not what it calls. What it calls is in Graph activity logs (available to Log Analytics or Sentinel via diagnostic settings — the
MicrosoftGraphActivityLogstable) or in app governance. Thirty days of those logs is a definitive answer: an app withMail.ReadWritewhose only calls arePOST /users/x/sendMailneedsMail.Send. - For Exchange-related permissions, an application access policy (
New-ApplicationAccessPolicy) can scope an app's mailbox permissions to a set of mailboxes, without changing the Graph grant. This is often the fastest reduction available: the app keepsMail.Readbut only over the three shared mailboxes it services. Its successor, role-based access control for applications in Exchange Online, does the same with finer scopes. - For SharePoint,
Sites.SelectedreplacesSites.ReadWrite.Allfor apps that work on specific sites; the app keeps a single Graph permission and per-site grants are added withGrant-PnPAzureADAppSitePermissionor the Graphsites/x/permissionsendpoint.
Step 3: reduce, with the app still running
The overlap approach, in the spirit of secret rotation: add the narrower permission first, confirm the app works with it, then remove the broad one.
- Grant the narrower permission (admin consent for the new scope, or a
Sites.Selectedsite grant, or an application access policy). - Watch the app's activity for a full cycle of its work.
- Remove the broad grant: for application permissions,
Remove-MgServicePrincipalAppRoleAssignmenton the Graph SP with the assignment ID; for delegated,Remove-MgOauth2PermissionGrantwith the grant ID, or edit the grant'sScopestring to drop only the offending scopes. - Watch for
403 Forbiddenin the app's own logs or in Graph activity logs. Anything that appears is a call the app makes that you did not see in step 2; restore the specific scope it needs, not the broad one.
Removing a delegated admin consent affects every user of the app at once; removing a user's own consent affects only them. Both take effect on the app's next token request, which for an app with a cached token can be up to an hour.
For a user-consented app from another tenant with no legitimate purpose, the cleaner move is to delete the service principal entirely, which revokes every grant and every token — the unused apps cleanup covers the recovery window if you are wrong.
Step 4: stop the growth
- Turn off unrestricted user consent. Allow user consent only for verified publishers and low-impact permissions, or not at all, and route the rest through the admin consent workflow so a human sees every request for
Mail.Read. The app consent policies guide has the settings. - A review step for application permissions in your change process: anyone requesting
*.Allon Graph justifies why*.Selected, an access policy, or a delegated scope will not do. - A quarterly export of both lists diffed against the last one. New high-privilege grants are the only lines that matter, and there are rarely more than a few.
- App governance if you are licensed for it; its unused-permission signal is worth more than any manual review.
What needs support or downtime
Neither, provided the overlap order is kept. The realistic failure is an integration losing a call it made once a quarter; the fix is restoring one scope, and the Graph activity logs make it a five-minute diagnosis instead of a ticket with the vendor. Microsoft support has no role here at all — every grant in the tenant is yours to change.
Further reading
Spot something wrong or want a topic covered? Send it through the contact form.