Securing Applications and Automation - Training Series
Section 2 of 6
Reference Guide

Permissions and Scopes

OAuth Permissions, Admin Consent, and Security Attacks


Section
Section 2 - Permissions and Scopes
Document Type
Reference Guide
Format
Instructor-led with guided lab
Audience
Infrastructure and security administrators

Section purpose

This section builds on the service principal identity model from Section 1 and focuses on what those identities are allowed to do once they authenticate. The goal is to give you a working understanding of OAuth permissions, the admin consent model, common attack techniques that exploit the permission system, and the governance tools available to harden your tenant against them.

Learning Objectives

✦ Learning Journey ✦ 1 2 3 N S W E Permissions delegated vs. app Consent OAuth flow & attacks Attacks token theft · phishing SECURE

Section 2 learning progression - permissions → consent → attacks → governance

API permissions: delegated versus application

Every permission grant in Microsoft Entra is either delegated or application.

DimensionDelegated PermissionApplication Permission
User contextApp acts on behalf of a signed-in userApp acts as itself - no user context
Scope boundaryUser's own authorization is the ceiling - the app cannot do more than the user canCovers the entire tenant scope of the data, not just one user's data
Graph objectOAuth2PermissionGrantappRoleAssignment
Token claimscp (scope)roles
Consent modelUser can sometimes consent independently, depending on tenant policyAlways requires admin consent - no user can self-consent
Blast radiusBounded by the specific user's accessTenant-wide - every mailbox, every file, every user
⚠️Risk - Application Permissions Are the #1 Blast-Radius Risk

Think of it as: permission scope × tenant population = blast radius. An app with Mail.ReadWrite.All application permission can silently read and modify every mailbox in the tenant. An app with Mail.Read delegated permission from a specific user can only access that user's mail. Application permissions always multiply across the entire tenant.

DELEGATED PERMISSION User App signs in to Entra ID Resource API token (scp) Bounded by user's own permissions APPLICATION PERMISSION App Only Entra ID client_credentials ALL Tenant Data token (roles) No user context - tenant-wide access

Delegated Vs App

Delegated Permission Flow 👤 User Signs in to App App Requests token delegates Entra ID Issues scp token scp claim Resource API Graph / SharePoint Bounded by User's Own Rights App inherits user's permission ceiling - no elevation User Permission Ceiling

Delegated permission flow - app inherits the signed-in user's permission ceiling

client_credentials roles claim ! App Only (no user context) Entra ID admin consent required ENTIRE TENANT SCOPE No user = no ceiling on blast radius

Application permission - no user context, no ceiling on blast radius

OAuth consent flow

The authorization code flow for delegated access follows this path:

  1. User visits the application.
  2. App redirects the browser to the Entra authorization endpoint with the requested scopes.
  3. User sees a consent prompt listing the publisher name, permission descriptions, and data access summary.
  4. User consents (or admin pre-consented on behalf of the organization).
  5. Entra issues an authorization code to the redirect URI.
  6. App exchanges the code for an access token and, where the flow supports it, a refresh token.
  7. App presents the access token as a bearer token to the target API.

Admin consent changes the flow so that one privileged action approves the permission for the entire organization. This removes the per-user consent prompt and grants tenant-wide access.

Static consent declares all required permissions in the app registration upfront. Dynamic consent allows delegated permissions to be requested progressively at runtime - the app can ask for Mail.Read only when the user actually navigates to the email feature. Application permissions cannot use dynamic consent; they are always declared statically and require admin consent.

📘Key Concept - The .default Scope

When an app requests https://graph.microsoft.com/.default, it gets all statically-configured permissions - this is the standard pattern in client_credentials (app-only) flows. In delegated flows, .default requests everything the app is registered for. Understanding .default is essential because most app-only token requests use this shortcut.

User/Browser App Entra ID Consent UI Resource API ① visit app ② redirect + scopes ③ consent prompt ④ auth code ⑤ code → access token ⑥ bearer token call Browser redirect flow Server-to-server (token) API call

OAuth consent flow - authorization redirect → consent prompt → auth code → token exchange

DELEGATED: Mail.Read Signed-in User 📬 My Mail User B 🔒 User C 🔒 ✅ Scope: 1 user's mailbox APPLICATION: Mail.ReadWrite.All App (no user) 📬 CEO 📬 CFO 📬 All Staff ⚠ Scope: every mailbox in tenant The Admin Consent Decision User consent User approves for themselves only Low blast radius Admin consent One click → all users, all data Maximum blast radius

Consent is the control surface - permission scope × tenant population = blast radius

STATIC CONSENT First sign-in All permissions shown at once User accepts or rejects ALL Grant created All-or-nothing - no partial consent DYNAMIC CONSENT First sign-in Only User.Read (minimal scope) Feature triggered → request Mail.Read Incremental grant Progressive - ask only when needed ⚠ APPLICATION PERMISSIONS - NEITHER MODEL APPLIES Application permissions always require admin consent upfront - no user-driven flow Admin consent workflow: users request → admins approve → no hard block on productivity

Static vs dynamic consent - and the special case for application permissions

Illicit consent grant attacks

Consent is also the primary attack vector for OAuth-based phishing. In an illicit consent grant attack:

  1. An attacker registers a convincing-looking multi-tenant application.
  2. The attacker sends a phishing link to a target user.
  3. The user follows the link, reaches a real Entra consent prompt, and approves the permissions.
  4. The attacker now has a persistent OAuth token that bypasses MFA because the user already authenticated.
  5. The access continues even after the user changes their password, because the consent grant persists independently of the user's credentials.
⚠️Risk - Illicit Consent Grants Bypass MFA

Illicit consent grant attacks bypass MFA and persist across password changes. Revoking the access requires explicitly finding and removing the OAuth permission grant, not resetting the password.

Attacker Registers fake app Phish Email "Click to sync files" Victim Clicks link Real Entra Consent Prompt Legitimate UI - victim trusts it Persistent OAuth Token Survives password changes Attacker Receives Silent persistent access Password Change = No Effect OAuth grant persists independently of user credentials

Illicit consent grant - the consent prompt is real; the app is malicious; the token survives password changes

1 Attacker registers app "Microsoft Teams File Sync" Mail.Read + Files.ReadWrite.All 2 Phishing link sent Finance employee clicks Legitimate Entra consent prompt 3 Victim consents Grant object created Persistent OAuth token issued 4 Attacker reads mail and files silently No alerts by default - looks like legitimate app activity 5 Employee changes password - no effect OAuth token is independent of the user's password REMEDIATION STEPS 1. Find the grant Enterprise Apps → Permissions tab 2. Remove consent Remove-Mg Oauth2PermissionGrant 3. Revoke sessions Invalidate all refresh tokens 4. Block the app Disable the SP or restrict consent policy

Illicit consent grant - five-step inline example with remediation steps

Tenant controls that defend against this:

All Apps (Open) Verified Publishers Only Admin Consent Required App Governance Monitor Each layer reduces attack surface Combine for defense-in-depth

Tenant controls against illicit consent grant - layered funnel; combine for defense-in-depth

Grant → Revoke → Token Still Valid 1 Consent → OAuth2PermissionGrant created Visible in Enterprise Apps → Permissions 2 Remove-MgOauth2PermissionGrant Portal refreshed - grant object gone 3 Graph Explorer STILL works Existing access token remains valid until expiry Grant Removal ≠ Token Revocation

Consent grant visibility and revocation - grant removal does not revoke existing tokens

Non-Graph API permissions

Application permissions are not limited to Microsoft Graph. Any OAuth2-enabled API registered in Entra can be listed in an app's permission request. The most dangerous example is the Azure Service Management API:

⚠️Risk - Azure Service Management Permission

An application with Azure Service Management user_impersonation consent from a Global Administrator can manage the organization's entire Azure environment on behalf of that admin. This permission is rarely audited because most tooling focuses on Graph.

Other non-Graph API scopes frequently seen in tenant permission reviews:

? ? Microsoft Graph (audited, visible) Azure Mgmt API (BLIND SPOT) Grants here bypass the Graph audit trail

Non-Graph API permissions - Azure Service Management is the most dangerous blind spot

Application Administrator role: privilege escalation risk

The Application Administrator role is the primary delegated control point for managing applications in Entra. It is frequently over-assigned and poorly understood.

What Application Administrators can do:

What they cannot do without a more privileged role:

The privilege escalation path:

An Application Administrator who adds a new credential to a service principal that already holds high-privilege Graph permissions (granted by a Global Administrator) can then authenticate as that service principal and exercise those permissions.

⚠️Risk - Application Administrator Privilege Escalation

Application Administrator can impersonate any existing service principal in the tenant by adding credentials to it. If that service principal holds RoleManagement.ReadWrite.Directory, Application Administrator becomes an indirect path to Global Administrator.

Application Administrator should be treated as a highly privileged role, assigned only through PIM with just-in-time activation, and its credential modification and consent actions should be monitored via audit logs.

Action Application Administrator Requires Delegated permissions (any API) User or admin consent for OAuth2PermissionGrants ✅ Can consent - Application permissions (non-Graph APIs) e.g. Azure Service Management, custom APIs ✅ Can consent - Application permissions (Microsoft Graph) e.g. Mail.ReadWrite.All, Directory.ReadWrite.All ✗ Cannot consent Global Admin or Privileged Role Admin Add credentials to any SP Including SPs with high-privilege Graph permissions ⚠ ALWAYS can do this No consent needed - directory-level operation ⚠ PRIVILEGE ESCALATION PATH App Admin adds secret to SP that already has Mail.ReadWrite.All → authenticates as that SP Monitor: "Add credentials to service principal" audit events from Application Administrator role

What Application Administrator can and cannot consent to - and the privilege escalation gap

Application Administrator Service Principal Has Mail.ReadWrite.All 1. Add secret 2. Authenticate as that SP Using added client_secret Full Tenant Mail Access Every mailbox - no user interaction needed Privilege Escalation Without Graph Consent App Admin uses pre-existing grant, bypasses new consent Defense: PIM + Audit Monitor "Add credentials to service principal" events Scope App Admin with PIM just-in-time

Application Administrator privilege escalation path - add credentials to SP, then authenticate as that SP

Attack patterns targeting the OAuth model

Device code flow phishing

The device code flow is designed for devices that cannot open a browser. An attacker exploits it as follows:

  1. Attacker initiates a device code authentication request.
  2. Attacker sends the resulting user code to the victim in a phishing message.
  3. Victim enters the code at microsoft.com/devicelogin.
  4. Attacker receives valid access and refresh tokens without the victim's password or MFA.

Microsoft Threat Intelligence has tracked active campaigns using this technique (Storm-2372, active since 2024).

⚠️Risk - Device Code Flow Phishing

Device code flow phishing is currently one of the most active cloud attack vectors. If Conditional Access policies do not explicitly block device code flow, every user is a potential target with no password or MFA required.

Defense: block device code flow via Conditional Access authentication flow restrictions for all users except approved narrow scenarios such as conference room devices on a specific named network.

ATTACKER VICTIM 1. user_code (phishing) 2. poll /token 3. Enter code + MFA ✓ Entra ID 4. tokens! access + refresh No credential theft Password never captured Attack doesn't require it Phishing delivers a code, not a credential prompt Victim satisfies MFA Attacker's device code request is authenticated by the victim - not them MFA alone won't block this Attacker holds token pair access_token + refresh_token Long-lived access - no re-authentication needed Block in Conditional Access Authentication flows → Device code flow → Block Named Locations to scope

Storm-2372 device code phishing - victim satisfies MFA, attacker holds the tokens

Attacker Storm-2372 Entra ID Token endpoint Victim Target user Calls /devicecode endpoint Gets user_code + device_code ② Phishing: "Enter code ABCD1234" Victim visits microsoft.com/devicelogin Enters code, completes normal MFA ✓ Entra issues access + refresh tokens Polling loop receives them Attacker has valid tokens No password stolen. MFA satisfied by victim. Defense: Block device code flow in CA Conditions → Authentication flows → Block

Device code flow phishing - five-step sequence diagram, attacker and victim swim lanes

CA Policy: Block Device Code Conditional Access → New Policy Assignments Users: All Users Exclude: Break-glass group (required before enabling) Conditions Auth flows → ✓ Device code flow (often missed) Grant: BLOCK Device code flow sign-in attempts blocked Start: Report-Only Mode Review logs before enforcing → move to Enabled Confirm break-glass exclusion works Sign-in Logs: Blocked Attempt Status: Failure - Conditional Access policy blocked Auth flow: Device code flow Exception pattern: named location + specific app Document any legitimate device code use cases before blocking

Conditional Access policy blocking device code flow - assignments, conditions, grant, and report-only start

Token theft and pass-the-token attacks

Access tokens stored in browser local storage, environment variables, pipeline logs, or memory can be extracted and replayed from any machine. MFA is not re-prompted because the token is already authenticated.

Defense:

Browser CI/CD Logs Env Vars ACCESS TOKEN aud: graph.microsoft.com roles: Mail.ReadWrite.All exp: 1h MFA already satisfied ✓ stolen Attacker replay Graph API replay from any machine, any location Token Protection + CAE = defense

Token theft - tokens leak from browser storage, CI/CD logs, env vars; defense is Token Protection + CAE

Refresh token abuse

In delegated scenarios, refresh tokens can have sliding window lifetimes of 24 hours to 90 days. A stolen refresh token can generate new access tokens for persistent access long after the original session ended.

Defense:

Refresh Token Lifecycle Day 0 Day 60 Day 90 Stolen Refresh Token Loop Every request renews the 90-day window → infinite persistence refresh token → new access token Defenses Sign-in Frequency CA Force re-auth at interval Breaks persistent sessions FIDO2 Passkeys Non-exportable credential Harder to steal and replay Continuous Access Evaluation (CAE) Near-real-time revocation when user disabled or policy changed Revoke sessions separately from removing grants

Refresh token lifecycle - a stolen refresh token can maintain persistent access for up to 90 days

Over-permissioned service principal lateral movement

A compromised service principal with Mail.ReadWrite.All can silently read and modify every mailbox in the tenant. A service principal with Files.ReadWrite.All can access every SharePoint file. These are common in enterprise tenants because proof-of-concept apps accumulated high-privilege grants that were never cleaned up.

Discovering stale over-permissioned service principals:

MAIL.READWRITE.ALL FILES.READWRITE.ALL CEO Mailbox CFO Mailbox HR Mailbox Stale SP Compromised ⚠ over-permissioned POC from 2021 Finance SP Site Legal SP Site Executive SP Site One compromised SP → tenant-wide blast radius App Governance: find last-used, unowned SPs

Over-permissioned service principal lateral movement - one stale SP gives tenant-wide blast radius

End-to-end OAuth attack chain

1 CredentialDiscoverySecret found inGitHub commitby former dev↳ Use secret scanning 2 Silent AuthNo ResistanceNo MFA, no CApolicy appliesSign-in looks↳ Workload ID CA 3 Silent DataExfiltrationMail.ReadWrite.All - 3 weeksof CEO/CFO mail↳ App Governance 4 PrivilegeEscalationApp Admin phished →adds cred to SP withRoleManagement perm↳ Block device code

Attack Chain

The following scenario connects every risk covered in this section into a single coherent attack.

The setup: An app registration called "HR Sync Tool" was created 18 months ago for a proof of concept. A Global Administrator granted it Mail.ReadWrite.All application permission. The developer who created it has left. The app has an active client secret with six months remaining. No owner is assigned.

Step 1 - Credential discovery: The attacker searches GitHub and finds a config file committed by the former developer containing the client secret.

Step 2 - Silent authentication: The attacker authenticates as the service principal using the stolen secret. No MFA fires. No Conditional Access policy applies. The sign-in looks normal.

Step 3 - Silent exfiltration: With Mail.ReadWrite.All, the attacker silently reads email from the CEO, CFO, and Legal team for three weeks. No user is prompted. The access token is continuously re-acquired via client credentials.

Step 4 - Privilege escalation: A second attacker compromises an Application Administrator account via device code phishing. The Application Administrator adds a new credential to an existing service principal that holds RoleManagement.ReadWrite.Directory. The attacker now has effective Global Administrator-equivalent access.

What detection would have looked like:

📘Key Concept - Detection ≠ Prevention

Each detection fires after the damage starts. The earlier controls - secret scanning, workload identity CA, least-privilege consent, credential governance policies - are preventive. Detection buys you response time, not immunity. Build both layers.

ATTACK STEP 1. Credential found Secret in GitHub commit by former developer 2. Silent SP auth No MFA, no CA policy fires for app-only flow 3. Mail exfiltration Mail.ReadWrite.All 3 weeks of CEO/CFO mail 4. Privilege escalation App Admin adds cred to SP with RoleManagement perm DETECTION GitHub Secret Scanning Push protection blocks secrets at commit time + Entra credential expiry alerts Workload ID Premium SP sign-in from unrecognized IP Anomalous credential usage alert App Governance Mass mail access from stale, low-activity SP + Defender for Cloud Apps Sentinel / Audit Log New credential added to SP by App Admin Built-in analytic rule template All of these are available as templates - not custom builds from scratch

Attack chain detection - each step maps to a specific detection tool, all available as templates

Where does your defense apply? 1 Credential Discovery Secret in GitHub commit Secret scanning Prevent/detect 2 Silent Auth No MFA, no CA policy applies Workload ID CA Block/alert on anomaly 3 Silent Exfiltration Mail.ReadWrite.All - 3 weeks App Governance Anomaly detection 4 Privilege Escalation App Admin phished → add cred to SP Block device code CA + PIM for App Admin Your controls stop the chain at…? Identify the earliest detection point in your environment Step 1? Step 2? Step 3?

Attack chain debrief - where do your controls apply, and where is the earliest detection point?

Permission hardening and governance

Discovering existing permissions

Use the Entra admin center (Enterprise Applications > Permissions) to review delegated and application permissions granted to each enterprise app.

Use Microsoft Defender for Cloud Apps App Governance for a unified dashboard showing:

Use PowerShell to enumerate permissions at scale:

# Install once on machines that do not already have the required Graph SDK submodules.
# If an older Microsoft.Graph.Authentication version is already loaded in this session,
# start a new pwsh session after installation before you run Connect-MgGraph.
Install-Module Microsoft.Graph.Identity.SignIns,Microsoft.Graph.Applications `
  -Scope CurrentUser `
  -Force `
  -AllowClobber

Import-Module Microsoft.Graph.Identity.SignIns
Import-Module Microsoft.Graph.Applications
Connect-MgGraph -Scopes "Application.Read.All","DelegatedPermissionGrant.ReadWrite.All"

# Enumerate application permission grants for the disposable demo app created in Step 0.
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $demoSpObjectId

# Enumerate delegated permission grants for the same demo app.
Get-MgOauth2PermissionGrant -Filter "clientId eq '$demoSpObjectId'"
Entra Portal Per-app view App Governance Best at scale PowerShell Get-Mg* Graph API Bulk queries Grant Inventory Permissions Mgmt retiring Nov 2025

Four tools for permission discovery - App Governance is the best for cross-app tenant-wide visibility

Revoking permissions after consent

Permissions granted via admin consent can be removed without reinstalling the app.

# Remove a specific delegated permission grant.
# Use the disposable demo grant created in Step 0 or another grant ID you already enumerated.
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $demoDelegatedGrant.id

Removing a permission does not revoke existing valid tokens. Token revocation is a separate action.

Two-Step Revocation Model 1 Remove-MgOauth2PermissionGrant Directory object removed - no future tokens issued ⚠ Existing tokens still valid Tokens are Independent of Grants Access tokens live until their expiry (typically 60–75 min) 2 Revoke-MgUserSignInSession Invalidates all refresh tokens immediately Next refresh attempt fails - forces full re-auth Complete Revocation Achieved Grant removed + sessions revoked = no further access App Governance Policy Automatically flag high-privilege consent requests Proactive: block before grant is created No app reinstall needed - grants are directory objects

Revoking permissions after consent - two-step revocation: remove grant + revoke sessions

Entitlement Management for governed API permissions

Microsoft Entra Entitlement Management can manage access packages that include app role assignments. Instead of permanent admin consent grants, you create an access package that:

  1. Contains a specific app role assignment (e.g., a custom API role or a Graph application permission via group-based assignment)
  2. Requires an approval workflow - the requestor submits a justification, an approver reviews
  3. Has a time-bound expiration - the assignment automatically expires after a set period (e.g., 30 days)
  4. Supports renewal with re-approval, or auto-expiry if not renewed

This is useful when developers or operators need temporary elevated API access without leaving permanent high-privilege grants in the tenant.

📘Key Concept - Governance Licensing

Both Entitlement Management and Lifecycle Workflows require Entra ID Governance or Entra Suite licensing. Without this license, the only path is manual permission grant management and manual offboarding cleanup.

Lifecycle Workflows

Lifecycle Workflows automate identity tasks triggered by employee lifecycle events:

Custom task extensions let you call out to Logic Apps or Function Apps when built-in tasks are not sufficient. For example, a Leaver workflow could trigger a Logic App that disables a service principal, removes its credentials, or queues credential rotation work for apps owned by the departing employee. There is no service-principal sign-in-session revoke endpoint in Graph.

This connects the identity governance story back to automation: the managed identities and Graph API calls covered in earlier sections are what power custom Lifecycle Workflow extensions.

LIFECYCLE WORKFLOWS JOINER New employee start date Trigger: employeeHireDate MOVER Department or role change Trigger: attribute change LEAVER Termination or offboarding Trigger: employeeLeaveDateTime Provision access packages Add to groups, assign app roles Reassign access packages Remove old, grant new roles Revoke all access Disable, revoke sessions, remove ENTITLEMENT MANAGEMENT Operator Requests access Approval Gate Manager or team lead Access Package App roles + group + time limit Time-limited API access Auto-expires, no persistent grants Both require Entra ID Governance licensing • Custom task extensions connect to automation patterns in later sections

Entitlement Management and Lifecycle Workflows - JML triggers + approval-gated time-limited access packages

Lab readiness notes

🧪Lab Readiness

Have Graph Explorer access if permitted, Microsoft Graph PowerShell modules, and the disposable demo object IDs ready. If tenant policy blocks Graph Explorer consent, continue with the provided demo grants. Clean up delegated grants, app role assignments, and disposable apps after the lab.

Guided lab

Lab goal

Use Graph Explorer to experience delegated versus application-only access, review App Governance for overprivileged applications, revoke a delegated permission grant, and verify a Conditional Access policy blocking device code flow.

Step 0: Prepare a disposable demo app and grants

Use one setup path and keep the variables it creates in the same terminal session. Each example creates a disposable app, a delegated grant, and an application grant so the later steps have predictable objects to inspect and revoke.

Native PowerShell:

$tenantId = "<tenant-id>"

az login --tenant $tenantId
$accessToken = az account get-access-token `
  --resource-type ms-graph `
  --tenant $tenantId `
  --query accessToken `
  -o tsv

$demoName = "Session2-PermissionsDemo-$(Get-Date -Format 'yyyyMMddHHmmss')"
$demoApp = az ad app create --display-name $demoName --sign-in-audience AzureADMyOrg | ConvertFrom-Json
$demoSp = az ad sp create --id $demoApp.appId | ConvertFrom-Json

$demoAppObjectId = $demoApp.id
$demoSpObjectId = $demoSp.id
$signedInUserId = az ad signed-in-user show --query id -o tsv

$graphSp = Invoke-RestMethod `
  -Method Get `
  -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'" `
  -Headers @{ Authorization = "Bearer $accessToken" }

$graphSpId = $graphSp.value[0].id

$demoDelegatedGrant = Invoke-RestMethod `
  -Method Post `
  -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" `
  -Headers @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" } `
  -Body (@{
      clientId    = $demoSpObjectId
      consentType = "Principal"
      principalId = $signedInUserId
      resourceId  = $graphSpId
      scope       = "openid profile offline_access User.Read"
  } | ConvertTo-Json)

$demoAppRoleAssignment = Invoke-RestMethod `
  -Method Post `
  -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$demoSpObjectId/appRoleAssignments" `
  -Headers @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" } `
  -Body (@{
      principalId = $demoSpObjectId
      resourceId  = $graphSpId
      appRoleId   = "498476ce-e0fe-48b0-b801-37ba7e2685c6"
  } | ConvertTo-Json)

Graph PowerShell:

# Run once on machines that do not already have the required Graph PowerShell submodules.
Install-Module Microsoft.Graph.Identity.SignIns,Microsoft.Graph.Applications `
  -Scope CurrentUser `
  -Force `
  -AllowClobber

Import-Module Microsoft.Graph.Identity.SignIns
Import-Module Microsoft.Graph.Applications

Connect-MgGraph -Scopes "Application.ReadWrite.All","Application.Read.All","DelegatedPermissionGrant.ReadWrite.All","AppRoleAssignment.ReadWrite.All","User.Read"

$demoName = "Session2-PermissionsDemo-$(Get-Date -Format 'yyyyMMddHHmmss')"
$demoApp = New-MgApplication -DisplayName $demoName -SignInAudience "AzureADMyOrg"
$demoSp = New-MgServicePrincipal -AppId $demoApp.AppId

$demoAppObjectId = $demoApp.Id
$demoSpObjectId = $demoSp.Id
$signedInUser = Get-MgUser -UserId (Get-MgContext).Account
$graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" | Select-Object -First 1

$demoDelegatedGrant = New-MgOauth2PermissionGrant -BodyParameter @{
    clientId    = $demoSpObjectId
    consentType = "Principal"
    principalId = $signedInUser.Id
    resourceId  = $graphSp.Id
    scope       = "openid profile offline_access User.Read"
}

$demoAppRoleAssignment = New-MgServicePrincipalAppRoleAssignment `
  -ServicePrincipalId $demoSpObjectId `
  -BodyParameter @{
      principalId = $demoSpObjectId
      resourceId  = $graphSp.Id
      appRoleId   = "498476ce-e0fe-48b0-b801-37ba7e2685c6"
  }

Az CLI:

$tenantId = "<tenant-id>"

az login --tenant $tenantId

$demoName = "Session2-PermissionsDemo-$(Get-Date -Format 'yyyyMMddHHmmss')"
$demoApp = az ad app create --display-name $demoName --sign-in-audience AzureADMyOrg | ConvertFrom-Json
$demoSp = az ad sp create --id $demoApp.appId | ConvertFrom-Json

$demoAppObjectId = $demoApp.id
$demoSpObjectId = $demoSp.id
$signedInUserId = az ad signed-in-user show --query id -o tsv
$graphSpId = az rest `
  --method get `
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'" `
  --query "value[0].id" `
  --output tsv

$delegatedGrantBodyPath = Join-Path $env:TEMP "session2-delegated-grant.json"
$appRoleAssignmentBodyPath = Join-Path $env:TEMP "session2-app-role-assignment.json"

(@{
    clientId    = $demoSpObjectId
    consentType = "Principal"
    principalId = $signedInUserId
    resourceId  = $graphSpId
    scope       = "openid profile offline_access User.Read"
} | ConvertTo-Json -Compress) | Set-Content -Path $delegatedGrantBodyPath -Encoding ascii

$demoDelegatedGrant = az rest `
  --method post `
  --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" `
  --headers "Content-Type=application/json" `
  --body "@$delegatedGrantBodyPath" | ConvertFrom-Json

(@{
    principalId = $demoSpObjectId
    resourceId  = $graphSpId
    appRoleId   = "498476ce-e0fe-48b0-b801-37ba7e2685c6"
} | ConvertTo-Json -Compress) | Set-Content -Path $appRoleAssignmentBodyPath -Encoding ascii

$demoAppRoleAssignment = az rest `
  --method post `
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$demoSpObjectId/appRoleAssignments" `
  --headers "Content-Type=application/json" `
  --body "@$appRoleAssignmentBodyPath" | ConvertFrom-Json

Remove-Item $delegatedGrantBodyPath,$appRoleAssignmentBodyPath

Step 1: Sign in to Graph Explorer and review the consent prompt

Entra ID / Graph Explorer:

Review the delegated consent request
  1. Open https://developer.microsoft.com/en-us/graph/graph-explorer in a browser.
  2. Select Sign in to Graph Explorer.
  3. Review the consent prompt: note the app name, publisher, and listed delegated permissions.
  4. Accept the consent.

If Graph Explorer is blocked by user-consent policy in your tenant, continue with the disposable demo app and delegated grant from Step 0. The grant object behaves the same for enumeration and revocation.

Step 2: Run delegated Graph calls

Entra ID / Graph Explorer:

Call /me
  1. In Graph Explorer, run GET https://graph.microsoft.com/v1.0/me.

Expected: returns your own user object. This is delegated access.

Call /users and expand scope
  1. Run GET https://graph.microsoft.com/v1.0/users.
  2. If prompted, request the User.Read.All scope and re-run the call.

Expected: the first call returns a 403 Forbidden until the broader delegated scope is consented. After consent, the users call succeeds.

Native PowerShell:

# Reuse the signed-in user token from Step 0.
$headers = @{ Authorization = "Bearer $accessToken" }

Invoke-RestMethod `
  -Method Get `
  -Uri "https://graph.microsoft.com/v1.0/me" `
  -Headers $headers

# This returns 403 unless the current client has User.Read.All consented.
Invoke-RestMethod `
  -Method Get `
  -Uri "https://graph.microsoft.com/v1.0/users" `
  -Headers $headers

Graph PowerShell:

Connect-MgGraph -Scopes "User.Read"
Get-MgUser -UserId "me"

# Reconnect with the broader delegated scope if you want to test directory reads.
Connect-MgGraph -Scopes "User.Read","User.Read.All"
Get-MgUser -Top 5

Az CLI:

az login --tenant $tenantId

az rest `
  --method get `
  --url "https://graph.microsoft.com/v1.0/me"

# This returns 403 unless the current Azure CLI client has User.Read.All consented.
az rest `
  --method get `
  --url "https://graph.microsoft.com/v1.0/users"

These alternate clients use their own app registrations instead of Graph Explorer, but they demonstrate the same delegated model: the token carries scp scopes and stays bounded by user context.

Step 3: Confirm the consent grant in Entra

Entra Portal:

Review the enterprise app permissions
  1. Go to the Entra admin center > Enterprise Applications.
  2. Search for Graph Explorer to review the delegated grant created in Step 1.
  3. Then search for the disposable Session2-PermissionsDemo-* enterprise app from Step 0 and open Permissions.
  4. Review the delegated grant on the disposable demo app so the code paths below target the same object.

Record the permission grant IDs you can see.

The code paths below intentionally query the disposable demo app from Step 0 because it gives you a predictable delegated grant ID for enumeration and revocation.

Native PowerShell:

Invoke-RestMethod `
  -Method Get `
  -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$demoSpObjectId'" `
  -Headers @{ Authorization = "Bearer $accessToken" }

Graph PowerShell:

Get-MgOauth2PermissionGrant -Filter "clientId eq '$demoSpObjectId'" |
  Select-Object Id, Scope, ConsentType, PrincipalId

Az CLI:

$grants = az rest `
  --method get `
  --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$demoSpObjectId'" | ConvertFrom-Json

$grants.value | Select-Object id, scope, consentType, principalId

Step 4: Enumerate and revoke a delegated permission grant

Entra Portal:

Refresh the permissions view after revocation
  1. Keep the disposable demo app Permissions blade open from Step 3.
  2. After you run one of the revocation commands below, refresh the page and verify that the delegated grant disappears.

Native PowerShell:

$grants = Invoke-RestMethod `
  -Method Get `
  -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$demoSpObjectId'" `
  -Headers @{ Authorization = "Bearer $accessToken" }

$grants.value | Select-Object id, scope, consentType

# Revoke a specific delegated grant.
# $grantId = $demoDelegatedGrant.id
# Invoke-RestMethod `
#   -Method Delete `
#   -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$grantId" `
#   -Headers @{ Authorization = "Bearer $accessToken" }

Graph PowerShell:

Import-Module Microsoft.Graph.Identity.SignIns
Connect-MgGraph -Scopes "DelegatedPermissionGrant.ReadWrite.All"

$grants = Get-MgOauth2PermissionGrant -Filter "clientId eq '$demoSpObjectId'"
$grants | Select-Object Id, Scope, ConsentType

# Remove the disposable demo grant from Step 0, or another non-essential grant ID you found above.
# Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $demoDelegatedGrant.Id

Az CLI:

$grants = az rest `
  --method get `
  --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$demoSpObjectId'" | ConvertFrom-Json

$grants.value | Select-Object id, scope, consentType

# Revoke a specific delegated grant.
# $grantId = $demoDelegatedGrant.id
# az rest `
#   --method delete `
#   --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$grantId"
💡Pro Tip - Revocation Sequence Matters

Step 1: Remove the consent grant. Step 2: If a compromised user session is involved, revoke that user's refresh tokens separately. Step 3: For service principals, disable the app or rotate credentials and remember that existing access tokens remain valid until expiry.

Expected observation: the portal shows the permission gone after the revocation call runs. Existing Graph Explorer or delegated client sessions still work until their access tokens expire, demonstrating that permission revocation is not immediate token revocation.

Step 5: Review App Governance for overprivileged apps

Defender Portal:

Filter for high-privilege application permissions
  1. Open Microsoft Defender XDR.
  2. Navigate to Cloud Apps > App Governance.
  3. Filter to apps with Mail.ReadWrite.All or Files.ReadWrite.All application permissions.
  4. Review the last-used date, owner status, and permission level.

Expected: identify apps that are inactive, unowned, or holding high-privilege permissions that exceed their apparent purpose. This step is intentionally portal-first because App Governance is the purpose-built investigation surface.

Step 6: Verify Conditional Access blocking device code flow

Entra Portal:

Review or create a report-only policy
  1. Go to Protection > Conditional Access > Policies.
  2. Locate or create a policy that targets all users and blocks the device code authentication flow.
  3. Ensure the policy excludes your break-glass or admin account before enabling it.
  4. Confirm the policy is set to Report-only mode initially.

Expected: the policy appears in the list. In Report-only mode, sign-in logs show what would have been blocked without affecting current sessions.

Native PowerShell:

# Create a CA policy blocking device code flow (Report-only)
# Requires Security Administrator or Conditional Access Administrator.
# Replace the excluded user with a real break-glass account before using this outside a lab.
$policyBody = @{
    displayName = "Block Device Code Flow"
    state       = "enabledForReportingButNotEnforced"
    conditions  = @{
        clientAppTypes = @("all")
        applications   = @{
            includeApplications = @("All")
        }
        users          = @{
            includeUsers = @("All")
            excludeUsers = @("<break-glass-user-id>")
        }
        authenticationFlows = @{
            transferMethods = "deviceCodeFlow"
        }
    }
    grantControls = @{
        operator        = "OR"
        builtInControls = @("block")
    }
} | ConvertTo-Json -Depth 6

Invoke-RestMethod `
  -Method Post `
  -Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" `
  -Headers @{ Authorization = "Bearer $accessToken"; "Content-Type" = "application/json" } `
  -Body $policyBody

Graph PowerShell:

Import-Module Microsoft.Graph.Identity.SignIns
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess"

$params = @{
    displayName = "Block Device Code Flow"
    state       = "enabledForReportingButNotEnforced"
    conditions  = @{
        clientAppTypes = @("all")
        applications   = @{
            includeApplications = @("All")
        }
        users          = @{
            includeUsers = @("All")
            excludeUsers = @("<break-glass-user-id>")
        }
        authenticationFlows = @{
            transferMethods = "deviceCodeFlow"
        }
    }
    grantControls = @{
        operator        = "OR"
        builtInControls = @("block")
    }
}

New-MgIdentityConditionalAccessPolicy -BodyParameter $params

Az CLI:

$policyBodyPath = Join-Path $env:TEMP "session2-device-code-policy.json"

(@{
    displayName = "Block Device Code Flow"
    state       = "enabledForReportingButNotEnforced"
    conditions  = @{
        clientAppTypes = @("all")
        applications   = @{
            includeApplications = @("All")
        }
        users          = @{
            includeUsers = @("All")
            excludeUsers = @("<break-glass-user-id>")
        }
        authenticationFlows = @{
            transferMethods = "deviceCodeFlow"
        }
    }
    grantControls = @{
        operator        = "OR"
        builtInControls = @("block")
    }
} | ConvertTo-Json -Depth 6) | Set-Content -Path $policyBodyPath -Encoding ascii

az rest `
  --method post `
  --uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" `
  --headers "Content-Type=application/json" `
  --body "@$policyBodyPath"

Remove-Item $policyBodyPath

Step 7: Clean up the disposable demo objects

Native PowerShell:

Invoke-RestMethod `
  -Method Delete `
  -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($demoDelegatedGrant.id)" `
  -Headers @{ Authorization = "Bearer $accessToken" }

Invoke-RestMethod `
  -Method Delete `
  -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$demoSpObjectId/appRoleAssignments/$($demoAppRoleAssignment.id)" `
  -Headers @{ Authorization = "Bearer $accessToken" }

# Remove this too if you created the report-only device-code policy in Step 6.
# Invoke-RestMethod `
#   -Method Delete `
#   -Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/<policy-id>" `
#   -Headers @{ Authorization = "Bearer $accessToken" }

az ad sp delete --id $demoSpObjectId
az ad app delete --id $demoAppObjectId

Graph PowerShell:

Connect-MgGraph -Scopes "Application.ReadWrite.All","DelegatedPermissionGrant.ReadWrite.All","AppRoleAssignment.ReadWrite.All"

Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $demoDelegatedGrant.Id
Remove-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $demoSpObjectId -AppRoleAssignmentId $demoAppRoleAssignment.Id

# Remove this too if you created the report-only device-code policy in Step 6.
# Remove-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId "<policy-id>"

Remove-MgServicePrincipal -ServicePrincipalId $demoSpObjectId
Remove-MgApplication -ApplicationId $demoAppObjectId

Az CLI:

az rest `
  --method delete `
  --uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($demoDelegatedGrant.id)"

az rest `
  --method delete `
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$demoSpObjectId/appRoleAssignments/$($demoAppRoleAssignment.id)"

# Remove this too if you created the report-only device-code policy in Step 6.
# az rest `
#   --method delete `
#   --uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/<policy-id>"

az ad sp delete --id $demoSpObjectId
az ad app delete --id $demoAppObjectId

Quick recap questions

  1. What is the key difference between an scp claim and a roles claim in an access token?
  2. Why can an Application Administrator escalate to Global Administrator-equivalent access without having that role?
  3. Why does revoking a delegated permission grant not immediately stop an attacker who already has a valid token?
  4. What makes device code phishing effective against users who have MFA enrolled?
  5. Why should the Azure Service Management API permission be audited separately from Microsoft Graph permissions?

Key reminders

Admin Checklist App perms → admin consent → tenant-wide Consent grants outlive passwords App Admin → PIM-governed Block device code flow in CA App Governance → scale visibility

Admin takeaways checklist - the five non-negotiables from this section

THIS SESSION Permissions & Scopes NEXT SESSION Token Acquisition A IMDS Endpoint How managed identities acquire tokens from Azure metadata B MSAL Library Token cache, retry, and refresh mechanics for automation C Managed Identity + Grants Connecting MI token flow to the permission grants from this session The Token is the Permission in Action Everything learned about scopes and consent flows into next section

Next-section bridge - this session's permissions become next session's tokens in action

Current source notes

Volatile platform behavior and dated claims in this section were checked against these current sources on April 26, 2026:

References

References Appendix

All links below were reviewed on 2026-03-10.

OAuth permissions and consent framework

Illicit consent grant and app governance

Device code flow phishing and token attacks

Application Administrator role and privilege escalation

Permission hardening and governance

Community and MVP references

Section 2 - Permissions and Scopes - Reference Guide