Application.ReadWrite.All: the Permission That Grants Every Other
Case study: preventing privilege escalation through a compromised provisioning app.
The scenario
A Microsoft 365 managed service provider runs 120 customer tenants. Its provisioning tool automatically creates Entra ID applications for each new onboarded client: a standard set of four apps (monitoring, backup, reporting, ticketing). The tool holds Application.ReadWrite.All in each tenant, granted at install time.
On a Tuesday evening, an MSP engineer receives an alert: the tool just created a new application in 37 tenants, called O365-Compliance-Sync, and granted it Mail.ReadWrite.All, Files.ReadWrite.All, and User.Read.All. Right after, the application started reading mailboxes and SharePoint libraries.
An MSP CI server had been compromised the day before. The attacker had extracted the provisioning tool's client secret. Since that secret opened 120 tenants, and since Application.ReadWrite.All allows granting any other permission, the rest was mechanical.
The alert came in because the MSP had wired a custom webhook on new app creation events. Without that webhook, nothing. Native Entra audit sees the event, but no one reads it in real time.
Why Microsoft doesn't block this scenario
Application.ReadWrite.All is the root permission of application management in Entra ID. By design, it is sufficient to create an application, assign its service principal, grant it a permission, and sign admin consent on behalf of a human. That is exactly what legitimate provisioning tools do.
The problem is that there is no native subset. There is no Application.ReadWrite.AllExceptHighRisk, no Application.ReadWrite.SelfAndDescendants. Once granted, the app can grant any other Graph permission, including ones normally reserved for Global Admins.
- Admin consent required for an elevated permission can be pre-granted by the app holding
Application.ReadWrite.All, via the/oauth2PermissionGrantsand/appRoleAssignmentsendpoints. - Conditional Access Policies for applications apply to users, not to application-bound Graph calls.
- App governance policies (Entra App Governance, Defender for Cloud Apps) detect risky apps, often after their first effective use.
- Entra audit captures the creation event, but security teams rarely review it in near real time.
The pattern is structurally the same as for Sites.FullControl.All, but more severe: this is the permission for escalation, not just pivot.
What the incident looks like with a perimeter in place
With Entra ID calls (/applications, /servicePrincipals, /oauth2PermissionGrants, /appRoleAssignments) routed through a governance proxy. Baseline: default-deny.
Rule 1 — App creation limited to a named template
- name: "Provisioning — app creation allowlist"
endpoint_pattern: "/applications"
http_methods: [POST]
conditions:
all_of:
- caller_identity:
application_id: "app-provisioning-msp"
- request_body:
path: "displayName"
matches_any: ["MSP-Monitor-*", "MSP-Backup-*", "MSP-Report-*", "MSP-Ticket-*"]
action: allow
The provisioning tool creates four types of apps with standardized names. Any creation outside the legitimate pattern is refused. The O365-Compliance-Sync app from the scenario could never be created.
Rule 2 — Grantable permissions limited to an allowlist
- name: "Provisioning — permission grant allowlist"
endpoint_pattern: "/servicePrincipals/*/appRoleAssignments"
http_methods: [POST]
conditions:
all_of:
- request_body:
path: "appRoleId"
in_reference: "approved_graph_roles"
action: allow
The approved_graph_roles list holds the appRoleId GUIDs for the permissions MSP apps actually need (read-only, narrow scope). Any attempt to grant Mail.ReadWrite.All, Files.ReadWrite.All, Directory.ReadWrite.All, or Application.ReadWrite.All is refused, even from the legitimate provisioning tool.
Rule 3 — Structural block on very high-risk permissions
- name: "Global — never grant tier-zero"
endpoint_pattern: "/servicePrincipals/*/appRoleAssignments"
http_methods: [POST]
conditions:
any_of:
- request_body:
path: "appRoleId"
in_reference: "tier_zero_graph_roles"
action: block
The tier_zero_graph_roles list holds permissions that no tenant application should ever grant itself, regardless of the tool: RoleManagement.*.Directory, Application.ReadWrite.All, Directory.ReadWrite.All. A global safety belt, indifferent to caller identity.
Rule 4 — Approval flow on destructive operations
- name: "Provisioning — app delete requires approval"
endpoint_pattern: "/applications/*"
http_methods: [DELETE]
conditions:
all_of:
- external_approval:
webhook: "https://msp-approvals.internal/graph-delete"
timeout_seconds: 300
action: allow
Application deletions wait for a human decision. The proxy calls an internal webhook that triggers a notification in the MSP's approval system. Five minutes to respond, otherwise refused. An attacker trying to erase traces is slowed down by a human control.
Rule 5 — Rate and window
- name: "Provisioning — rate + window"
endpoint_pattern: "/applications"
http_methods: [POST, PATCH, DELETE]
conditions:
all_of:
- rate_limit:
max: 5
window_seconds: 3600
scope: "caller_identity"
- time_window:
timezone: "America/Montreal"
days: ["Mon", "Tue", "Wed", "Thu", "Fri"]
hours: "08:00-18:00"
action: allow
Five app operations per hour is enough for normal onboarding, even for a multi-tenant MSP. The 37-creation burst from the scenario would be cut at the fifth. The time window covers the classic off-hours attack vector.
Allowlists and denylists by appRoleId, approval webhooks, and burst detection are part of the base GraphWarden grammar. See the documentation for the full rule syntax.
See the rule documentation →What changes for the decision-maker
Escalation via Application.ReadWrite.All is the scenario that turns a single developer workstation compromise into total tenant compromise. Native tooling detects. Detection prevents little. The difference is measured in the number of tenants touched before the first human intervention.
With a governance layer in front of Graph, three shifts.
- High-risk permissions can no longer be auto-granted. The tier-zero rule cuts escalation at the root, regardless of which tool requests it.
- Destructive operations require human validation. An attacker under time pressure loses the advantage of speed.
- Audit already contains the incident signal. Every refused grant attempt is a high-value alert, directly correlated to an application identity.
The limits worth naming
The permission allowlists (approved_graph_roles, tier_zero_graph_roles) need maintenance. The tier-zero list is stable, a set of roughly twenty permissions that evolves slowly. The MSP allowlist evolves with application needs, which requires a light but regular process.
The approval webhook introduces an external dependency. If the approval system is down or under maintenance, the operation is refused (fail-safe). That is the right tradeoff for destructive operations, not for transactional traffic.
Last limit: the proxy must be on the path. For a multi-tenant provisioning tool, that means every client tenant routes its Graph traffic through a dedicated endpoint. The architecture is proven. It is the primary MSP deployment pattern for GraphWarden.
The takeaway
Application.ReadWrite.All is the worst-calibrated permission in the Graph catalog: necessary to any serious provisioning tool, and simultaneously the root of nearly every tenant-wide escalation. The safe version does not exist on Microsoft's side.
The question is not whether a tool with this permission will eventually be compromised. The question is whether your architecture, on that day, lets it grant every other Graph permission to other applications. With a governance layer, the answer is no, by construction.
That is exactly why GraphWarden exists.