A missing server-side authorization check on a GraphQL removeUser mutation let a lower-privileged Admin delete the organization's highest-privileged account.
A privilege-escalation flaw was identified in the application's GraphQL API that allowed a user holding only Admin privileges to delete a Super Admin account — the highest-privileged role in the organization.
The root cause was a missing server-side authorization check on the removeUser mutation. The backend accepted a user-supplied target identifier (userEntityId) and executed the deletion without verifying whether the requesting user was permitted to act on a user of equal or higher privilege.
Because the Super Admin typically represents the ultimate owner of an organization's tenant, an attacker with an Admin account could remove that owner, seize effective control of the organization, and potentially lock out legitimate administrators. This is a textbook horizontal-and-vertical access-control failure with organization-wide blast radius — rated Critical.
Multi-tenant SaaS applications commonly implement a role hierarchy:
| Role | Typical capabilities |
|---|---|
| Super Admin / Owner | Full control of the tenant, billing, and all users. Can create/remove Admins. |
| Admin | Manage most resources and standard users, but not the owner. |
| Member / User | Day-to-day usage with limited scope. |
The implicit security contract is straightforward: a role can only manage roles at or below its own level. An Admin managing Members is expected; an Admin deleting the Super Admin violates the model's core invariant. The frontend UI almost certainly hides the "remove" action for higher-privileged users — but the API underneath never enforced the same rule, and the API is the real security boundary.
The application exposes a GraphQL mutation, removeUser(userEntityId: String!), that deletes the user matching the supplied identifier. When an Admin invokes it, the backend:
userEntityId was supplied. ✅That third step is the failure. Authentication was present; authorization was absent. The system answered "Are you logged in?" but never asked "Are you allowed to do this to this user?"
This is a classic case of client-side–only access control. The frontend renders a permission-aware interface, but permission logic that lives only in the browser is advisory, not enforceable. Any user can bypass the UI by crafting the underlying API request directly with an intercepting proxy.
The steps below are documented for defensive validation and remediation verification. The target host and identifiers are placeholders/redacted.
Step 1 — Authenticate as a lower-privileged user. Log in with a standard Admin account and capture its session cookie.
Step 2 — Intercept an authenticated request. Route the browser through an intercepting proxy (Burp Suite, OWASP ZAP) to capture a valid, authenticated GraphQL request and its session token.
Step 3 — Forge the privileged mutation. Replay the request as a removeUser mutation, substituting the Super Admin's entity ID as the target:
POST /api/graphql HTTP/2
Host: <redacted>
Cookie: <ATTACKER_ADMIN_SESSION>
Content-Type: application/json
{
"operationName": "RemoveUser",
"variables": {
"userEntityId": "<SUPER_ADMIN_ID>"
},
"query": "mutation RemoveUser($userEntityId: String!) {
removeUser(userEntityId: $userEntityId)
}"
}
Step 4 — Send. Submit the request. Step 5 — Observe. The API returns success and the Super Admin account is deleted, despite the requester holding only Admin privileges. The authorization boundary was never enforced.
The finding decomposes into three distinct backend gaps:
userEntityId came straight from the client and selected the deletion target with no accompanying entitlement check.Underlying all three is a single architectural mistake: treating the frontend as the enforcement point. The UI hid the action, so the risk was assumed handled. In reality the API is the security boundary — and it was left open.
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:N/I:H/A:H → Base 8.7 (High)
| Metric | Value | Reasoning |
|---|---|---|
| Attack Vector | Network | Exploited over the API. |
| Attack Complexity | Low | Single crafted request; no special conditions. |
| Privileges Required | High | Requires a valid Admin account. |
| User Interaction | None | No victim action needed. |
| Scope | Changed | Impact crosses the intended authorization boundary. |
| Confidentiality | None | No direct data disclosure. |
| Integrity | High | Unauthorized destruction of a privileged account. |
| Availability | High | Loss of the highest administrative access. |
Note on scoring: if the Admin role is treated as low-privilege relative to the Super Admin, PR:L yields 9.6 (Critical). Given the organization-wide blast radius, the finding is triaged as Critical regardless of PR interpretation.
Add an explicit entitlement check inside the removeUser resolver before any deletion occurs:
function removeUser(requester, targetId):
target = lookupUser(targetId)
if target is null:
return notFound()
# 1. Hierarchy: requester must outrank (or equal) the target
if roleRank(requester) < roleRank(target):
return forbidden("Insufficient privilege to remove this user")
# 2. Protect the owner
if target.role == SUPER_ADMIN and requester.role != SUPER_ADMIN:
return forbidden()
# 3. Tenant boundary
if requester.orgId != target.orgId:
return forbidden()
performDelete(target)
auditLog(actor=requester, action="removeUser", target=target)
return success()
id must always be paired with an entitlement check.Most access-control flaws hide behind a permission-aware UI. Farchase tests the boundary attackers actually reach — the API.