Performing OAuth Scope Minimization Review
When to Use
- Annual or quarterly review of third-party application OAuth permissions
- After a security incident involving compromised OAuth tokens or unauthorized data access
- Compliance audit requiring documentation of third-party data access (GDPR Article 28, SOC 2)
- Discovery of shadow IT applications accessing organizational data via OAuth grants
- Migration or consolidation of SaaS applications requiring permission cleanup
- Implementing least-privilege principle for API integrations
Do not use for reviewing first-party application permissions within the same trust boundary; OAuth scope minimization focuses on third-party and cross-boundary consent grants.
Prerequisites
- Admin access to identity providers (Microsoft Entra ID, Okta, Google Workspace)
- Microsoft Graph API permissions: Application.Read.All, OAuth2PermissionGrant.ReadWrite.All
- Inventory of approved third-party integrations from procurement or IT governance
- OAuth scope risk classification framework
- Tools for token analysis (jwt.io for manual review, automated scripts for bulk analysis)
Workflow
Step 1: Inventory All OAuth Grants and Consent Permissions
Enumerate all OAuth application registrations and delegated permissions:
"""
OAuth Grant Inventory - Microsoft Entra ID
Enumerates all application registrations, service principals,
and delegated/application permission grants.
"""
import requests
import json
from collections import defaultdict
class EntraOAuthAuditor:
def __init__(self, tenant_id, client_id, client_secret):
self.tenant_id = tenant_id
self.base_url = "https://graph.microsoft.com/v1.0"
self.token = self._get_token(client_id, client_secret)
self.headers = {"Authorization": f"Bearer {self.token}"}
def _get_token(self, client_id, client_secret):
url = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
response = requests.post(url, data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://graph.microsoft.com/.default"
})
return response.json()["access_token"]
def get_all_service_principals(self):
"""Get all service principals (enterprise applications)."""
apps = []
url = f"{self.base_url}/servicePrincipals?$top=999&$select=id,appId,displayName,appOwnerOrganizationId,servicePrincipalType,accountEnabled,createdDateTime"
while url:
response = requests.get(url, headers=self.headers)
data = response.json()
apps.extend(data.get("value", []))
url = data.get("@odata.nextLink")
return apps
def get_oauth2_permission_grants(self):
"""Get all delegated permission grants (user consent)."""
grants = []
url = f"{self.base_url}/oauth2PermissionGrants?$top=999"
while url:
response = requests.get(url, headers=self.headers)
data = response.json()
grants.extend(data.get("value", []))
url = data.get("@odata.nextLink")
return grants
def get_app_role_assignments(self, sp_id):
"""Get application permission assignments for a service principal."""
url = f"{self.base_url}/servicePrincipals/{sp_id}/appRoleAssignments"
response = requests.get(url, headers=self.headers)
return response.json().get("value", [])
def build_permission_inventory(self):
"""Build comprehensive OAuth permission inventory."""
service_principals = self.get_all_service_principals()
delegated_grants = self.get_oauth2_permission_grants()
sp_map = {sp["id"]: sp for sp in service_principals}
inventory = []
for grant in delegated_grants:
sp = sp_map.get(grant["clientId"], {})
scopes = grant.get("scope", "").split()
for scope in scopes:
if not scope:
continue
inventory.append({
"app_name": sp.get("displayName", "Unknown"),
"app_id": grant.get("clientId"),
"publisher_tenant": sp.get("appOwnerOrganizationId"),
"is_third_party": sp.get("appOwnerOrganizationId") != self.tenant_id,
"permission_type": "Delegated",
"scope": scope,
"consent_type": grant.get("consentType"),
"principal_id": grant.get("principalId"),
"granted_date": sp.get("createdDateTime"),
"is_enabled": sp.get("accountEnabled", True)
})
for sp in service_principals:
app_roles = self.get_app_role_assignments(sp["id"])
for role in app_roles:
inventory.append({
"app_name": sp.get("displayName"),
"app_id": sp.get("id"),
"publisher_tenant": sp.get("appOwnerOrganizationId"),
"is_third_party": sp.get("appOwnerOrganizationId") != self.tenant_id,
"permission_type": "Application",
"scope": role.get("appRoleId"),
"consent_type": "AdminConsent",
"granted_date": role.get("createdDateTime"),
"is_enabled": sp.get("accountEnabled", True)
})
return inventory
Step 2: Classify OAuth Scopes by Risk Level
Categorize permissions based on data access sensitivity:
"""
OAuth Scope Risk Classification
Maps API scopes to risk levels based on data sensitivity and access breadth.
"""
MICROSOFT_GRAPH_SCOPE_RISK = {
"critical": {
"scopes": [
"Directory.ReadWrite.All",
"RoleManagement.ReadWrite.Directory",
"Application.ReadWrite.All",
"AppRoleAssignment.ReadWrite.All",
"Mail.ReadWrite",
"Mail.Send",
"Files.ReadWrite.All",
"Sites.FullControl.All",
"User.ReadWrite.All",
"Group.ReadWrite.All",
"MailboxSettings.ReadWrite",
"full_access_as_app",
],
"risk_description": "Can read/write all data, modify directory, or impersonate users",
"review_frequency": "Monthly",
"requires_admin_consent": True
},
"high": {
"scopes": [
"Mail.Read",
"Mail.Read.Shared",
"Calendars.ReadWrite",
"Contacts.ReadWrite",
"Files.Read.All",
"Sites.Read.All",
"User.Read.All",
"Group.Read.All",
"Directory.Read.All",
"AuditLog.Read.All",
"SecurityEvents.ReadWrite.All",
"TeamSettings.ReadWrite.All",
],
"risk_description": ,
: ,
:
},
: {
: [
,
,
,
,
,
,
,
,
,
],
: ,
:
},
: {
: [
,
,
,
,
,
,
,
,
],
: ,
:
}
}
():
risk_level, config MICROSOFT_GRAPH_SCOPE_RISK.items():
scope config[]:
{
: scope,
: risk_level,
: config[],
: config[]
}
{
: scope,
: ,
: ,
:
}
():
risk_weights = {: , : , : , : }
total_score =
classified_scopes = []
perm app_permissions:
classification = classify_scope_risk(perm[])
classified_scopes.append(classification)
total_score += risk_weights.get(classification[], )
app_type_permissions = [p p app_permissions p[] == ]
total_score += (app_type_permissions) *
admin_consent = [p p app_permissions p[] == ]
total_score += (admin_consent) *
total_score >= :
aggregate_risk =
total_score >= :
aggregate_risk =
total_score >= :
aggregate_risk =
:
aggregate_risk =
{
: total_score,
: aggregate_risk,
: (app_permissions),
: ([s s classified_scopes s[] == ]),
: ([s s classified_scopes s[] == ]),
: classified_scopes
}
Step 3: Identify Over-Permissioned Applications
Detect apps requesting more permissions than functionally needed:
"""
Over-Permission Detection
Identifies applications with excessive OAuth scopes relative to their function.
"""
def detect_over_permissions(inventory, approved_apps_catalog):
"""
Compare actual permissions against approved scope catalog
to find over-permissioned applications.
"""
findings = []
app_permissions = defaultdict(list)
for perm in inventory:
app_permissions[perm["app_name"]].append(perm)
for app_name, permissions in app_permissions.items():
approved = approved_apps_catalog.get(app_name)
if not approved:
findings.append({
"app_name": app_name,
"finding_type": "UNAPPROVED_APPLICATION",
"severity": "HIGH",
"detail": f"Application not in approved catalog with {len(permissions)} permission grants",
"scopes": [p["scope"] for p in permissions],
"recommendation": "Review and approve or revoke all permissions"
})
continue
approved_scopes = set(approved.get("approved_scopes", []))
actual_scopes = set(p["scope"] for p in permissions)
excessive = actual_scopes - approved_scopes
excessive:
risk = analyze_app_risk([p p permissions p[] excessive])
findings.append({
: app_name,
: ,
: risk[],
: ,
: (excessive),
: (approved_scopes),
:
})
unused = approved_scopes - actual_scopes
unused:
findings.append({
: app_name,
: ,
: ,
: ,
: (unused)
})
broad_patterns = [
(, , ),
(, , ),
(, , ),
(, , ),
]
broad, narrow, description broad_patterns:
broad actual_scopes:
findings.append({
: app_name,
: ,
: ,
: description,
: broad,
: narrow,
:
})
findings
Step 4: Audit Token Usage and Detect Stale Grants
Identify OAuth tokens that are no longer actively used:
"""
Token Usage Audit
Analyzes sign-in logs and API activity to identify stale OAuth grants.
"""
def audit_token_usage(auditor, days_inactive=90):
"""Identify OAuth grants with no recent API activity."""
url = f"{auditor.base_url}/auditLogs/signIns"
params = {
"$filter": f"createdDateTime ge {(datetime.utcnow() - timedelta(days=days_inactive)).isoformat()}Z and signInEventTypes/any(t: t eq 'servicePrincipal')",
"$top": 999
}
active_apps = set()
while url:
response = requests.get(url, headers=auditor.headers, params=params)
data = response.json()
for signin in data.get("value", []):
active_apps.add(signin.get("appId"))
url = data.get("@odata.nextLink")
params = {}
all_grants = auditor.get_oauth2_permission_grants()
sp_map = {sp["id"]: sp for sp in auditor.get_all_service_principals()}
stale_grants = []
for grant in all_grants:
sp = sp_map.get(grant["clientId"], {})
app_id = sp.get("appId")
if app_id and app_id not in active_apps:
stale_grants.append({
"app_name": sp.get("displayName", "Unknown"),
"app_id": app_id,
"scopes": grant.get("scope", ).split(),
: grant.get(),
: sp.get() != auditor.tenant_id,
: days_inactive,
:
})
(stale_grants, key= x: (x[]), reverse=)
Step 5: Generate Remediation Plan and Execute Scope Reduction
Create and execute the scope minimization remediation plan:
"""
OAuth Scope Remediation
Generates and executes scope reduction actions.
"""
def generate_remediation_plan(findings, stale_grants):
"""Create prioritized remediation plan."""
plan = []
for f in findings:
if f["finding_type"] == "UNAPPROVED_APPLICATION":
plan.append({
"priority": 1,
"action": "REVOKE_ALL_PERMISSIONS",
"app_name": f["app_name"],
"reason": "Unapproved third-party application",
"impact": f"Removes {len(f['scopes'])} permission grants",
"risk_if_not_addressed": "CRITICAL"
})
for f in findings:
if f["finding_type"] == "EXCESSIVE_SCOPES":
plan.append({
"priority": 2,
"action": "REMOVE_EXCESSIVE_SCOPES",
"app_name": f["app_name"],
"scopes_to_remove": f["excessive_scopes"],
"reason": "Scopes beyond approved catalog",
"risk_if_not_addressed": f[]
})
f findings:
f[] == :
plan.append({
: ,
: ,
: f[],
: f[],
: f[],
: f[],
:
})
grant stale_grants:
plan.append({
: ,
: ,
: grant[],
: grant[],
: ,
:
})
(plan, key= x: x[])
():
url =
response = requests.get(url, headers=auditor.headers)
current_grant = response.json()
current_scopes = (current_grant.get(, ).split())
updated_scopes = current_scopes - (scopes_to_remove)
updated_scopes:
requests.delete(url, headers=auditor.headers)
{: , : grant_id}
:
update_body = {: .join(updated_scopes)}
requests.patch(url, headers=auditor.headers, json=update_body)
{
: ,
: grant_id,
: (scopes_to_remove),
: (updated_scopes)
}
Key Concepts
| Term | Definition |
|---|
| OAuth Scope | Permission string defining the specific API access level granted to a client application (e.g., Mail.Read, Files.ReadWrite.All) |
| Delegated Permission | OAuth scope exercised on behalf of a signed-in user, limited by both the app's permissions and the user's own access rights |
| Application Permission | OAuth scope granted directly to the application without user context, providing access to all users' data (high risk) |
| Admin Consent | Tenant-wide permission grant made by an administrator that applies to all users without individual consent |
| Scope Minimization | Security principle of reducing OAuth permissions to the minimum set required for application functionality |
| Stale Grant | OAuth permission that remains active but has no recent API usage, indicating the integration is abandoned or deprecated |
Tools & Systems
- Microsoft Entra Admin Center: Portal for reviewing enterprise applications, consent permissions, and OAuth grant management
- Nudge Security: SaaS security platform for discovering OAuth grants, assessing third-party risk, and automating scope reviews
- Cerby: Non-SSO application management platform for auditing OAuth integrations and managing shared accounts
- Microsoft Graph API: Programmatic interface for enumerating and modifying OAuth permission grants at scale
Common Scenarios
Scenario: Post-Breach OAuth Scope Audit
Context: After a phishing attack compromised an admin account, investigation reveals the attacker registered a malicious OAuth application with Mail.ReadWrite and Files.ReadWrite.All scopes, exfiltrating 6 months of email. The organization needs a comprehensive OAuth scope review.
Approach:
- Immediately revoke all OAuth grants from the compromised admin session
- Enumerate all service principals and permission grants across the tenant
- Flag all applications registered in the last 90 days for manual review
- Classify all third-party application scopes using the risk framework
- Identify applications with critical scopes (Mail.ReadWrite, Files.ReadWrite.All, Directory.ReadWrite.All)
- Cross-reference against approved application catalog from IT procurement
- Revoke all unapproved applications immediately
- Downgrade over-permissioned approved applications to minimum required scopes
- Implement admin consent workflow to prevent future uncontrolled OAuth grants
- Enable consent policy requiring admin approval for high-risk scopes
Pitfalls:
- Revoking permissions for business-critical integrations without coordination causes service disruption
- Not checking for application-level permissions (vs delegated) which are higher risk and often overlooked
- Missing multi-tenant applications where the publisher tenant differs from the consuming tenant
- Not implementing ongoing monitoring to detect new unauthorized OAuth grants after remediation
Output Format
OAUTH SCOPE MINIMIZATION REVIEW REPORT
=========================================
Tenant: corp.onmicrosoft.com
Review Period: 2026-02-01 to 2026-02-24
Total Applications: 147
Third-Party Apps: 98
First-Party Apps: 49
PERMISSION INVENTORY
Total OAuth Grants: 487
Delegated Permissions: 312
Application Permissions: 175
Admin-Consented: 89
User-Consented: 223
RISK CLASSIFICATION
Critical Risk Apps: 7
- UnknownCRMApp (Mail.ReadWrite, Files.ReadWrite.All - UNAPPROVED)
- LegacySync (Directory.ReadWrite.All - EXCESSIVE)
- DevToolX (Application.ReadWrite.All - OVERLY BROAD)
High Risk Apps: 18
Medium Risk Apps: 34
Low Risk Apps: 88
FINDINGS
Unapproved Applications: 12 (REVOKE IMMEDIATELY)
Excessive Scopes: 23 apps with scopes beyond approved list
Overly Broad Permissions: 15 apps that can be downgraded
Stale Grants (90+ days): 31 apps with no recent API activity
REMEDIATION PLAN
Priority 1 (Immediate): 12 unapproved app revocations
Priority 2 (This Week): 23 excessive scope removals
Priority 3 (This Month): 15 scope downgrades
Priority 4 (Next Quarter): 31 stale grant revocations
Estimated Scope Reduction: 34% of total permissions