Identity Azure

Governing OAuth Consent and Application Permissions in Entra ID: Stopping Illicit Consent and Hardening App Trust

The fastest way into a tenant in 2026 is not a stolen password — MFA killed most of that. It is a user clicking “Accept” on a malicious OAuth app that asks for Mail.Read and offline_access, and now an attacker has a refresh token that survives password resets and laughs at Conditional Access. Illicit consent grants are the quiet persistence mechanism, and the default Entra ID consent settings leave the door propped open. This is the build I use to govern the entire consent surface: restrict who can consent to what, stand up an approval workflow, constrain app credentials, and then actually hunt the grants that slipped through.

Most of this needs Privileged Role Administrator or Global Administrator for the policy changes, Cloud Application Administrator for app management, and Security Reader / Security Operator for the hunting. App management policies and several settings are Graph-only — the portal does not expose them — so assume Microsoft Graph PowerShell or direct Graph calls throughout.

1. Delegated vs application permissions: where over-consent risk concentrates

Two permission models, two completely different blast radii. Conflating them is the root cause of most consent incidents.

The dangerous middle is delegated permissions that a user can consent to. By default, users can consent to apps requesting delegated permissions classified as low impact, but the historical defaults were far too permissive and let users grant scopes like Mail.Read and Files.ReadWrite.All to any app. The attack pattern — call it illicit consent grant or OAuth phishing — is a lookalike app that walks a user through a real Microsoft sign-in, requests offline_access plus a data scope, and banks the refresh token.

The token from a consent grant is not a session. Revoking sign-in sessions, resetting the password, and re-enrolling MFA do nothing to a granted OAuth refresh token. The only kill switch is deleting the oauth2PermissionGrant (delegated) or the appRoleAssignment (application) and disabling the service principal. Treat consent as a distinct, durable trust relationship.

2. Restrict user consent and classify low-risk scopes

The first lever is the tenant authorization policy: stop users from consenting to arbitrary apps for arbitrary scopes. The right posture for most enterprises is “users may consent only to verified publishers, and only for permissions you have classified as low risk.”

Connect-MgGraph -Scopes "Policy.ReadWrite.Authorization","Policy.ReadWrite.PermissionGrant"

# Inspect the current default user role permissions, including consent.
(Get-MgPolicyAuthorizationPolicy).DefaultUserRolePermissions |
  Select-Object PermittedToCreateApps, AllowedToCreateSecurityGroups

User consent is governed by the permission grant policies assigned to the default user role. To allow consent only for verified-publisher apps requesting low-impact (classified) permissions, assign the built-in ManagePermissionGrantsForSelf.microsoft-user-default-low policy. To disable user consent entirely, assign an empty array.

# Option A: users may consent only to low-risk, classified permissions from verified publishers.
az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
  --headers "Content-Type=application/json" \
  --body '{
    "defaultUserRolePermissions": {
      "permissionGrantPoliciesAssigned": [
        "ManagePermissionGrantsForSelf.microsoft-user-default-low"
      ]
    }
  }'

# Option B: disable user consent completely (everything goes through admin consent).
az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
  --headers "Content-Type=application/json" \
  --body '{ "defaultUserRolePermissions": { "permissionGrantPoliciesAssigned": [] } }'

“Low risk” only means something if you have classified which scopes are low risk. Out of the box almost nothing is classified, so the -low policy is effectively very tight. Add genuinely benign delegated scopes — openid, profile, email, offline_access, User.Read — to the low classification so legitimate sign-in apps still work without an admin in the loop.

# Resolve Microsoft Graph's service principal (the resource app) to classify its scopes.
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"

foreach ($name in @("openid","profile","email","offline_access","User.Read")) {
  $scope = $graphSp.Oauth2PermissionScopes | Where-Object { $_.Value -eq $name }
  if ($scope) {
    New-MgServicePrincipalDelegatedPermissionClassification `
      -ServicePrincipalId $graphSp.Id `
      -Classification "low" `
      -PermissionId $scope.Id `
      -PermissionName $scope.Value
  }
}

Classify deliberately and sparingly. Anything that reads user data at scale (Mail.Read, Files.Read.All, Sites.Read.All, Contacts.Read) must never be low — leaving those out forces them through admin consent, which is exactly the point.

3. Stand up the admin consent request and approval workflow

If you tighten user consent without an escape valve, users hit “Need admin approval” and file a ticket — or worse, find an unmanaged personal-tenant workaround. The admin consent workflow turns that dead end into a routed, audited approval.

# Read the current admin consent request policy.
az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/policies/adminConsentRequestPolicy"

Enable it and nominate reviewers (users, groups, or roles). Reviewers get notified, see the requested permissions and who else asked, and approve or deny in one place. Keep the request TTL short so stale requests expire rather than lingering as a standing approval queue.

az rest --method PUT \
  --uri "https://graph.microsoft.com/v1.0/policies/adminConsentRequestPolicy" \
  --headers "Content-Type=application/json" \
  --body '{
    "isEnabled": true,
    "notifyReviewers": true,
    "remindersEnabled": true,
    "requestDurationInDays": 14,
    "reviewers": [
      {
        "query": "/groups/<APP-GOVERNANCE-REVIEWERS-GROUP-ID>/members",
        "queryType": "MicrosoftGraph"
      }
    ]
  }'

Make the reviewers a dedicated group of app-savvy approvers, not “all Global Admins.” The reviewer must read a permission set and decide whether Mail.Send application-wide is appropriate for a marketing SaaS tool. That is an architectural judgment, and routing it to whoever happens to hold GA guarantees rubber-stamping. Pair every approval with a one-line justification captured in the request.

4. App management policies: block risky credentials and permission configs

Restricting consent governs apps coming in. App management policies govern the apps you own — the registrations your own teams create — by enforcing rules on their credentials. The classic failure is a 2-year client secret that leaks into a pipeline log; app management policies let you ban long-lived and password (secret) credentials outright at the tenant level.

Connect-MgGraph -Scopes "Policy.ReadWrite.ApplicationConfiguration"

# Tenant default: block password (secret) credentials on apps created from now on,
# and cap any certificate (asymmetric) credential lifetime at 180 days.
$body = @{
  isEnabled = $true
  applicationRestrictions = @{
    passwordCredentials = @(
      @{
        restrictionType = "passwordAddition"
        state           = "enabled"
        maxLifetime     = $null
        restrictForAppsCreatedAfterDateTime = "2026-06-08T00:00:00Z"
      }
    )
    keyCredentials = @(
      @{
        restrictionType = "asymmetricKeyLifetime"
        state           = "enabled"
        maxLifetime     = "P180D"
        restrictForAppsCreatedAfterDateTime = "2026-06-08T00:00:00Z"
      }
    )
  }
}
Update-MgPolicyDefaultAppManagementPolicy -BodyParameter $body

restrictForAppsCreatedAfterDateTime is the safety valve: the restriction applies only to apps created after that timestamp, so you do not instantly break every existing registration. Roll the date forward as you remediate legacy apps. For stricter or looser rules on a specific high-value app, attach a custom appManagementPolicy to that application instead of relying on the tenant default.

# Create a named policy that bans secrets entirely, then attach it to one app.
POLICY_ID=$(az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/policies/appManagementPolicies" \
  --headers "Content-Type=application/json" \
  --body '{
    "displayName": "kv-no-secrets-cert-only",
    "isEnabled": true,
    "restrictions": {
      "passwordCredentials": [
        { "restrictionType": "passwordAddition", "state": "enabled", "maxLifetime": null }
      ]
    }
  }' --query id -o tsv)

az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/applications/<APP-OBJECT-ID>/appManagementPolicies/\$ref" \
  --headers "Content-Type=application/json" \
  --body "{ \"@odata.id\": \"https://graph.microsoft.com/v1.0/policies/appManagementPolicies/$POLICY_ID\" }"

This is how you make “no client secrets, certificates or federated credentials only” an enforced platform rule rather than a wiki page nobody reads. It pairs directly with workload identity federation — kill the credential as an option, not just as a guideline.

5. Hunt illicit consent grants and OAuth phishing in the logs

Even with tight policy, you investigate what got through and what predates the policy. Two log sources matter: the audit log records consent events, and sign-in logs record which apps are actually being used. In Log Analytics / Sentinel:

// Consent grants in the last 30 days, with who consented and to which app.
AuditLogs
| where TimeGenerated > ago(30d)
| where OperationName in ("Consent to application", "Add delegated permission grant",
                          "Add app role assignment grant to user", "Add app role assignment to service principal")
| extend target = tostring(TargetResources[0].displayName)
| extend initiatedByUpn = tostring(InitiatedBy.user.userPrincipalName)
| extend permissions = tostring(parse_json(tostring(TargetResources[0].modifiedProperties)))
| project TimeGenerated, OperationName, target, initiatedByUpn, permissions, Result
| order by TimeGenerated desc

The high-signal pattern for OAuth phishing: a brand-new service principal that suddenly receives delegated grants for offline_access plus a data scope, consented by an end user (not an admin), often clustered across multiple users in a short window after a phishing wave.

// Newly-consented apps reaching many distinct users fast (consent-phishing fan-out).
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName == "Consent to application"
| extend appName = tostring(TargetResources[0].displayName)
| extend appId   = tostring(TargetResources[0].id)
| extend upn     = tostring(InitiatedBy.user.userPrincipalName)
| summarize distinctUsers = dcount(upn), firstSeen = min(TimeGenerated),
            lastSeen = max(TimeGenerated), users = make_set(upn, 25)
          by appName, appId
| where distinctUsers >= 5 and (lastSeen - firstSeen) < 24h
| order by distinctUsers desc

Cross-reference with sign-in logs to see whether the app is active (tokens being issued), which raises priority for revocation.

// Service-principal (app-only) sign-ins for a suspect app — is it actually being used?
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(30d)
| where AppId == "<SUSPECT-APP-ID>"
| summarize signIns = count(), resources = make_set(ResourceDisplayName, 10),
            ips = make_set(IPAddress, 20) by AppId, ServicePrincipalName

6. Review and revoke risky enterprise app and service-principal grants

When you find a bad grant, enumerate the exact permissions and pull the kill switch. First, inventory delegated grants and app-role (application permission) assignments for a service principal.

$spId = (Get-MgServicePrincipal -Filter "appId eq '<SUSPECT-APP-ID>'").Id

# Delegated (user/admin-consented) permission grants.
Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $spId |
  Select-Object Id, ConsentType, PrincipalId, Scope

# Application permissions (app role assignments TO this SP, e.g. Graph app roles).
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $spId |
  Select-Object Id, ResourceDisplayName, AppRoleId, PrincipalDisplayName

Revoke surgically (one grant) or fully (disable the SP so no new tokens issue). Disabling AccountEnabled is the fastest containment; deleting the grants removes the standing authorization.

# Containment: block new sign-ins / token issuance for the app immediately.
Update-MgServicePrincipal -ServicePrincipalId $spId -AccountEnabled:$false

# Remove every delegated grant for the SP.
Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $spId |
  ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }

# Remove every application-permission assignment for the SP.
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $spId |
  ForEach-Object {
    Remove-MgServicePrincipalAppRoleAssignment `
      -ServicePrincipalId $spId -AppRoleAssignmentId $_.Id
  }

Disabling the SP stops new tokens but already-issued access tokens remain valid until expiry (default ~60-90 minutes, and refresh tokens far longer). For a confirmed compromise, also revoke refresh tokens for affected users (Revoke-MgUserSignInSession) so the app cannot mint new access tokens off an existing refresh token, and rotate any secret the app may have exfiltrated.

7. Continuous governance: registration reviews and credential expiry monitoring

Consent governance is not a one-time hardening pass; new apps and new credentials arrive every week. Two recurring jobs keep it honest.

Credential expiry monitoring. Surface every app secret and certificate, sorted by expiry, so nothing dies in production at 2am and nothing lingers years past its purpose.

Get-MgApplication -All -Property "displayName,appId,passwordCredentials,keyCredentials" |
  ForEach-Object {
    $app = $_
    @($app.PasswordCredentials) + @($app.KeyCredentials) | Where-Object { $_ } | ForEach-Object {
      [pscustomobject]@{
        App      = $app.DisplayName
        AppId    = $app.AppId
        Type     = if ($_.Key) { "Certificate" } else { "Secret" }
        EndDate  = $_.EndDateTime
        DaysLeft = [int]($_.EndDateTime - (Get-Date)).TotalDays
      }
    }
  } | Sort-Object DaysLeft | Format-Table -AutoSize

Periodic permission review. Enumerate every service principal holding high-impact application permissions on Microsoft Graph — the grants an attacker most wants — and recertify them on a cadence.

$graphAppId = "00000003-0000-0000-c000-000000000000"
$graphSp = Get-MgServicePrincipal -Filter "appId eq '$graphAppId'"
$riskyRoles = @("Mail.Read","Mail.ReadWrite","Mail.Send","Files.Read.All",
                "Directory.ReadWrite.All","Application.ReadWrite.All","RoleManagement.ReadWrite.Directory")

$riskyRoleIds = $graphSp.AppRoles |
  Where-Object { $_.Value -in $riskyRoles } |
  Select-Object -Property Id, Value

Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $graphSp.Id -All |
  Where-Object { $_.AppRoleId -in $riskyRoleIds.Id } |
  Select-Object PrincipalDisplayName,
    @{n="Permission";e={ ($riskyRoleIds | Where-Object Id -eq $_.AppRoleId).Value }},
    CreatedDateTime

Wire both into your recertification program — an Entra access review scoped to high-privilege app owners, or a scheduled export into your governance backlog. The goal: no application permission survives without a named owner who re-attests it.

8. Integrate consent telemetry with Defender for Cloud Apps and Sentinel

Manual hunting does not scale past a few thousand users. Microsoft Defender for Cloud Apps (MDA) App Governance continuously scores OAuth apps on permission breadth, publisher verification, usage anomalies, and known-bad indicators, and can auto-disable an app that trips a policy — for example, “an unverified app newly granted high-privilege Graph permissions used by more than N users.” That closes the loop faster than any human review.

For the SIEM side, the consent and app-role audit events flow through the Entra ID / Microsoft 365 connectors into Sentinel, where you operationalize the hunts above as analytics rules. A consent-phishing fan-out rule:

# Sentinel scheduled analytics rule (concept) — codify the fan-out hunt.
displayName: "Illicit consent grant - app consented by many users rapidly"
severity: High
queryFrequency: PT1H
queryPeriod: PT24H
triggerOperator: GreaterThan
triggerThreshold: 0
tactics: [ Persistence, PrivilegeEscalation ]
query: |
  AuditLogs
  | where OperationName == "Consent to application"
  | extend appName = tostring(TargetResources[0].displayName),
           appId   = tostring(TargetResources[0].id),
           upn     = tostring(InitiatedBy.user.userPrincipalName)
  | summarize distinctUsers = dcount(upn), users = make_set(upn, 25),
              firstSeen = min(TimeGenerated)
            by appName, appId
  | where distinctUsers >= 5
entityMappings:
  - entityType: CloudApplication
    fieldMappings:
      - identifier: AppId
        columnName: appId

Pair the detection with an automated playbook (Logic App) that, on a high-confidence trigger, disables the offending service principal and opens an incident with the affected user list pre-populated — the same revocation moves from step 6, executed in seconds instead of after a ticket queue.

Verify

Validate the controls end to end before declaring victory:

  1. User consent is constrained. As a standard test user, attempt to consent to a third-party app requesting Mail.Read. You should hit “Need admin approval,” and an entry should appear under the admin consent requests for your reviewers.
# Confirm the authorization policy reflects the intended consent posture.
az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
  --query "defaultUserRolePermissions.permissionGrantPoliciesAssigned"
  1. Only the intended scopes are classified low. Anything beyond your benign sign-in scopes appearing here is a finding.
Get-MgServicePrincipalDelegatedPermissionClassification -ServicePrincipalId `
  (Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'").Id |
  Select-Object Classification, PermissionName
  1. App credential rules bite. On a newly created app, attempt to add a client secret and confirm Graph rejects it per the app management policy; existing apps (created before the cutoff) are unaffected.

  2. Admin consent workflow routes. Confirm a pending request lands for reviewers and that approve/deny is captured in the audit log.

az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/identityGovernance/appConsent/appConsentRequests"
  1. Hunting queries return. Run the fan-out KQL against a known recent consent and confirm it surfaces; verify the Sentinel rule has fired at least once in a controlled test (consent a benign app from a couple of test users).

Enterprise scenario

A platform team running a 25,000-seat tenant got the call every identity lead dreads: finance reported that a handful of users had clicked a “DocuSign-style” review link, and now an unfamiliar app was reading their mailboxes. The investigation in the audit log showed the textbook illicit consent grant — a multi-tenant app, consented by eleven users inside ninety minutes, requesting delegated Mail.Read and offline_access. Conditional Access had not blocked anything, because the users completed a legitimate Microsoft sign-in and MFA; the malicious step was the consent, which CA does not evaluate.

The constraint that made it ugly: user consent had been left at the historical default, so any user could grant Mail.Read to any app, and the org had no admin consent workflow — so even after tightening policy, they risked breaking dozens of sanctioned SaaS integrations that relied on self-service consent. They could not just slam the door.

They executed in three moves. First, immediate containment — disable the service principal and shred its grants tenant-wide:

$spId = (Get-MgServicePrincipal -Filter "appId eq '<MALICIOUS-APP-ID>'").Id
Update-MgServicePrincipal -ServicePrincipalId $spId -AccountEnabled:$false
Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $spId |
  ForEach-Object { Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $_.Id }
# Revoke refresh tokens for the affected users so the app can't mint new access tokens.
$affected | ForEach-Object { Revoke-MgUserSignInSession -UserId $_ }

Second, they switched user consent to ManagePermissionGrantsForSelf.microsoft-user-default-low and classified only openid, profile, email, offline_access, and User.Read as low — so legitimate sign-in apps kept working while anything touching mailboxes or files now required approval. Crucially, they enabled the admin consent workflow in the same change window, routing requests to a four-person app-governance group, which kept the sanctioned SaaS integrations unblocked through a fast approval path instead of a help-desk ticket.

Third — the durable fix — they onboarded MDA App Governance and authored a policy to auto-disable any unverified app newly granted high-privilege Graph permissions and used by 3+ users, plus a Sentinel analytics rule mirroring the fan-out query. The next consent-phishing attempt six weeks later was killed automatically: the app was disabled before a human looked at it, and the incident arrived pre-populated with the affected user list. The lesson the team wrote into their runbook: consent is a trust relationship, not a session — govern who can establish it, route the exceptions, and automate the kill.

Checklist

Entra IDOAuthConsentApp PermissionsSecOps

Comments

Keep Reading