☁️Entra IDApplicationsPermissionsIdentity

Entra Delegated Versus Application Graph API Permissions Least Privilege: The Consent Model Most Tenants Get Backwards

Delegated Graph permissions are not the safe ones. A tenant-wide AllPrincipals grant pre-authorises an app against every user in your directory — and lives in a collection that app-role reviews never read.

Younes AZABARBy Younes AZABAR13 min read
Entra Delegated Versus Application Graph API Permissions Least Privilege: The Consent Model Most Tenants Get Backwards

Every tenant eventually has to answer the Entra delegated versus application Graph API permissions least privilege question: which of the two consent models is actually safer, and how do you grant an app the minimum it needs without breaking it? Most teams answer it from instinct — delegated permissions "run as a user", so they feel bounded; application permissions "run as the app", so they feel dangerous. That instinct is half right, and the half that is wrong is the half nobody audits.

The permission that quietly gives an app a foothold in every mailbox in your tenant is not necessarily an application permission. It can be a delegated permission that an administrator consented to on behalf of the entire organisation — and that grant lives in a different object collection from the app roles, so an application-permission review never returns it.

Entra Delegated Versus Application Graph API Permissions Least Privilege, Defined

Microsoft's identity platform documents two access scenarios, and the distinction is about who the app acts as, not about how much damage it can do.

Delegated access means a user has signed in and the client application accesses the resource on their behalf. Microsoft is explicit about the ceiling: "The application isn't able to access anything the signed in user couldn't access." An app granted the delegated Files.Read.All permission "is only able to read files that the user can personally access." Delegated permissions are also called scopes, and the resulting grant object is an oAuth2PermissionGrant.

Application access (app-only) means the application acts on its own with no user signed in — daemons, backups, automation. Here the ceiling is the resource itself: an app granted the Microsoft Graph application permission Files.Read.All "is able to read any file in the tenant using Microsoft Graph." Application permissions are app roles, the grant object is an appRoleAssignment, and per Microsoft's own comparison table, only an admin can consent to them.

Delegated permissionsApplication permissions
Access contextOn behalf of a signed-in userWithout a user
Who can consentUsers for their own data; admins for all usersOnly an admin
Consent methodsStatic (app registration) or dynamic (requested at sign-in)Static only
Other namesScopes, OAuth2 permission scopesApp roles, app-only permissions
Grant objectoAuth2PermissionGrantappRoleAssignment

That last row is the whole problem in one line. The two permission types live in two different object collections. An audit that enumerates appRoleAssignments sees nothing of the delegated side, and vice versa: the two queries return disjoint sets, so a review that runs only one of them is silent about half the surface.

"Delegated is the safe one"

The sentence "the app can't do more than the user can" is true, and it is doing a lot of load-bearing work it was never meant to do.

A delegated grant carries a consentType field with two possible values. Microsoft's reference is unambiguous: "AllPrincipals indicates authorization to impersonate all users. Principal indicates authorization to impersonate a specific user." When consentType is AllPrincipals, principalId is null — the grant is not attached to anyone, because it applies to everyone.

So a tenant-wide admin consent for delegated Mail.ReadWrite does not mean the app can read all mail. It means the app is pre-authorised to act on the mailbox of any user who signs into it, with no consent prompt, for as long as the grant exists. The bound is still the signed-in user's own privileges — but the population is the whole directory, and the friction that would normally make a user stop and read a consent screen is gone.

Now consider who signs into internal tooling. If a Global Administrator or an Exchange Administrator authenticates to an app holding a tenant-wide delegated Directory.ReadWrite.All, the app inherits that session's reach. Delegated is not a ceiling on privilege; it is a ceiling that moves with whoever is logged in.

"Application permissions are the powerful ones, so ask for those"

The mirror error. Microsoft's own portal walkthrough presents Delegated permissions first and describes application permissions as the option for "service- or daemon-type applications that need to access a web API as themselves, without user interaction for sign-in or consent" — and that description is exactly why developers reach for them. A service that only ever needs to read one shared mailbox then ends up holding Mail.Read against the entire organisation, because that is the only granularity Entra ID offers on the Graph permission itself.

Microsoft's delegated-access primer is direct about which scenario is which: delegated access is "usually a poor choice for scenarios that must run without a signed-in user, like automation", while you should "use delegated access whenever you want to let a signed-in user work with their own resources or resources they can access." The failure mode in real tenants is choosing on convenience — whichever one authenticates without a browser prompt — rather than on which principal actually needs the data.

⚠️

⚠️ Warning: The two errors compound. A tenant that over-grants tenant-wide delegated scopes and unscoped application permissions has two independent, additive paths to the same data, tracked in two different places.

What a Tenant-Wide Delegated Grant Buys an Attacker

Delegated access requires two independent authorisations to succeed, and Microsoft documents them separately: client app authorization (the app was granted the scope, which lands in the token's scp claim) and user authorization (the resource checks the signed-in user's own rights). Microsoft's OneDrive example makes the matrix explicit — a request fails if either the scope is missing or the user has no rights to the object.

An attacker does not need to break that model. They need to satisfy both halves at once, and a tenant-wide grant hands them the first half for free:

  1. The scope is already granted. With consentType = AllPrincipals, there is no consent prompt to phish and no per-user grant to create. Any token the app obtains for any user carries the scope.
  2. The user half arrives with the victim. Whatever the compromised or phished user can reach, the app can now reach through the API, which is exactly the difference between "read this one inbox in Outlook" and "enumerate it programmatically at machine speed." And that reach is often wider than the org chart suggests — see over-privileged service principal admin roles.
  3. Revocation is not where you expect it. Microsoft's own guidance notes that in the Entra admin center "You can't revoke permissions in the User consent tab using the portal." Per-user delegated grants have to be removed through Graph or PowerShell.

This is the same consent surface abused by OAuth consent phishing, except no phishing is required — the grant is already in place, made by an administrator, usually years ago, usually for a tool that is still installed.

Two related registration properties widen the blast radius. signInAudience set to AzureADMultipleOrgs makes the registration multi-tenant — "users with a Microsoft work or school account in any organization's Microsoft Entra tenant" can sign in. And apps nobody uses any more keep their grants indefinitely: the oAuth2PermissionGrant object carries no expiry property, so nothing ages a grant out on its own, and a decommissioned integration with a live secret and a live tenant-wide grant is a credential waiting to be found. See also Entra app registration credential rotation and secrets.

Detection

Run these against your own tenant. Reading sign-in activity needs AuditLog.Read.All (least privileged) and one of the Reports Reader, Security Reader or Security Administrator roles; reading and revoking grants needs at least Cloud Application Administrator.

What to look forWhereSignal
Tenant-wide delegated grantsoauth2PermissionGrantsconsentType eq 'AllPrincipals' — applies to every user
Per-user delegated grantsoauth2PermissionGrantsconsentType eq 'Principal', not revocable in the portal
Application permissionsservicePrincipals/{id}/appRoleAssignmentsUnscoped, tenant-wide by construction
Multi-tenant registrationsapplicationssignInAudience eq 'AzureADMultipleOrgs'
Dormant apps/beta/reports/servicePrincipalSignInActivitieslastSignInActivity older than 90 days

Start with the grants that apply to everyone. consentType supports $filter with eq:

GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=consentType eq 'AllPrincipals'

Each result gives you clientId (the object id of the client service principal — not its appId), resourceId (the API being called), and scope, a space-separated list such as openid User.Read GroupMember.Read.All. The equivalent in Microsoft Graph PowerShell:

Connect-MgGraph -Scopes "Directory.Read.All"
Get-MgOauth2PermissionGrant -All | Where-Object { $_.ConsentType -eq 'AllPrincipals' } |
    Select-Object ClientId, ResourceId, Scope

Then pull the other collection for the same service principal, because the delegated query above will never show it:

GET https://graph.microsoft.com/v1.0/servicePrincipals/{id}/oauth2PermissionGrants
GET https://graph.microsoft.com/v1.0/servicePrincipals/{id}/appRoleAssignments

Multi-tenant registrations — signInAudience supports $filter with eq, ne and not:

GET https://graph.microsoft.com/v1.0/applications?$filter=signInAudience eq 'AzureADMultipleOrgs'

For dormant apps, service principal sign-in activity is a beta API — Microsoft states that APIs under /beta are subject to change and are not supported in production, so treat this as an audit input, not a control you build on. It supports $top, $filter and $orderby:

GET https://graph.microsoft.com/beta/reports/servicePrincipalSignInActivities
Import-Module Microsoft.Graph.Beta.Reports
Get-MgBetaReportServicePrincipalSignInActivity

Each record exposes lastSignInActivity plus a breakdown that tells you how the app is being used: delegatedClientSignInActivity (it is acting for a signed-in user) versus applicationAuthenticationClientSignInActivity (app-only, no user). That split is the fastest way to catch an app holding both kinds of permission while only ever exercising one of them, and it feeds directly into service principal sign-in anomaly detection.

Remediation

💡

💡 Quick Win: Enumerate consentType eq 'AllPrincipals' first and revoke the grants attached to apps nobody can name an owner for. It is the shortest path from audit to reduced blast radius.

1. Stop new over-consent at the door. In the Microsoft Entra admin center, go to Identity > Applications > Enterprise apps > Consent and permissions > User consent settings (Global Administrator for the portal; Privileged Role Administrator otherwise). By default, "all users are allowed to consent to applications for permissions that don't require administrator consent." The two built-in policies are microsoft-user-default-legacy (allow user consent for apps — any permission not requiring admin consent, any app) and microsoft-user-default-low (allow user consent only for apps from verified publishers or registered in your tenant, and only for permissions you have classified as low impact). Programmatically:

PATCH https://graph.microsoft.com/v1.0/policies/authorizationPolicy

{
    "defaultUserRolePermissions": {
        "permissionGrantPoliciesAssigned": [
            "managePermissionGrantsForSelf.microsoft-user-default-low",
            "managePermissionGrantsForOwnedResource.{other-current-policies}"
        ]
    }
}

Read the current authorizationPolicy first and preserve any existing ManagePermissionGrantsForOwnedResource.* entries, or you will silently remove developer self-service consent. Pair this with the admin consent workflow so users can request review instead of being dead-ended, and review it alongside the other tenant default user settings that govern what non-admins may create.

⚠️

⚠️ Warning: Changing this setting fixes the future only. Microsoft is explicit: "Any updates to user consent settings only affect future consent operations for applications. Existing consent grants remain unchanged." Tightening the policy does not revoke a single existing grant.

2. Revoke what is already there. Delegated and application permissions are removed through different endpoints:

DELETE https://graph.microsoft.com/v1.0/oAuth2PermissionGrants/{id}
DELETE https://graph.microsoft.com/v1.0/servicePrincipals/{resource-servicePrincipal-id}/appRoleAssignedTo/{appRoleAssignment-id}

Remember the portal caveat: the Admin consent tab supports revocation in the UI, the User consent tab does not. And revocation alone is not durable — Microsoft notes that "Revoking the current granted permission doesn't stop users from re-consenting to the application's requested permissions." You also have to remove the permission from what the app requests, or block user consent outright.

3. Scope application permissions instead of accepting tenant-wide. For Exchange workloads, RBAC for Applications lets you pair an app role with a resource scope so Mail.Read covers a filtered set of mailboxes rather than all of them. Assigning these roles requires membership of the Organization Management role group in Exchange Online, and the Exchange Administrator role in Microsoft Entra ID:

New-ServicePrincipal -AppId <appId> -ObjectId <servicePrincipalObjectId> -DisplayName "reporting-app"
New-ManagementScope -Name "Canadian users" -RecipientRestrictionFilter "CustomAttribute1 -eq '012332'"
New-ManagementRoleAssignment -App <servicePrincipalObjectId> -Role "Application Mail.Read" -CustomResourceScope "Canadian users"
Test-ServicePrincipalAuthorization -Identity "reporting-app" -Resource <targetMailbox> | Format-Table

There is a trap here that catches almost everyone. Microsoft's FAQ spells it out: permissions from the two authorities are a union, not an intersection. "If your Service Principal has Mail.Read granted in Microsoft Entra ID and you configure a resource-scoped Mail.Read permission in Application RBAC, it's important that you remove the assignment of Mail.Read from Microsoft Entra ID. Otherwise, the union ... results in no effective resource scoping." Scoping in Exchange while leaving the Entra grant in place changes nothing. Note also that permission changes are cached between 30 minutes and 2 hours (the test cmdlet bypasses the cache), and that exclusive management scopes do not restrict app access.

4. Retire dormant registrations. Anything with no lastSignInActivity for 90 days is a candidate for disable-then-delete. Do it in that order so you can restore quickly if a quarterly job turns out to depend on it. Deleting the service principal in Entra also removes its Exchange RBAC assignments automatically.

5. Make the review recurring, and cover both collections. A review that only reads appRoleAssignments will pass a tenant whose real exposure is a five-year-old AllPrincipals grant. Fold this into the wider Entra ID security audit rather than treating it as a one-off cleanup — consent grants have no expiry, so drift here is permanent until someone looks.

How EtcSec Detects This

EtcSec's Azure catalogue covers both halves of the consent model. APP_EXCESSIVE_DELEGATED flags applications holding more delegated scopes than their function justifies, and APP_CONSENT_GRANTED_TENANT_WIDE surfaces the AllPrincipals grants that a permissions review keyed on app roles will miss. APP_USER_CONSENT_UNRESTRICTED and APP_ADMIN_CONSENT_NOT_REQUIRED check the tenant-level settings that let those grants accumulate in the first place, while APP_MULTI_TENANT identifies registrations with signInAudience set beyond your own directory and APP_UNUSED_90_DAYS lists the registrations still holding live grants long after anyone stopped using them.

For the application-permission side of the same problem — Directory, Mail, Files and RoleManagement app roles specifically — see Entra app registration dangerous Graph API permissions and Azure app registrations: over-privileged tenant apps.

ℹ️

ℹ️ Note: EtcSec checks for these misconfigurations during every Entra ID audit. Run a free audit to see which of your app registrations hold tenant-wide grants today.

Sources

Explore the identity security pages that support this topic