What is privilege escalation in a web application?
Privilege escalation in a web app means a logged-in user does something their role was never meant to allow. The application authenticated them correctly; it just did not check, on that specific request, whether this user may touch this object or call this function.
There are two directions:
- Horizontal. Same role, someone else's data. User A reads or edits user B's project by changing an ID. In API terms this is broken object level authorization, and the older web name for the same bug is insecure direct object reference.
- Vertical. Higher role. A member calls an admin-only endpoint, changes their own role, or reaches another tenant's admin functions. In API terms this is broken function level authorization.
A vertical escalation where the UI hides the admin feature but the API does not enforce it:
POST /api/orgs/org_77/members/user_a/role HTTP/1.1
Host: api.example
Authorization: Bearer <user_a member token>
Content-Type: application/json
{"role": "admin"}HTTP/1.1 200 OK
Content-Type: application/json
{"user_id": "user_a", "org_id": "org_77", "role": "admin"}user_a is a plain member. The endpoint checked that the token was valid and that user_a belonged to org_77, then trusted the request. The server should have returned 403.
What does it look like in practice?
Most escalation bugs fall into a small set of patterns:
- Missing function-level checks. Admin routes protected only by the frontend: hidden buttons, an
/adminpath nobody links to, a GraphQL mutation left out of the member UI but present in the schema. - Client-controlled role fields. A signup or profile update that accepts
"role","is_admin"or"plan"in the body and binds it straight to the model. That is mass assignment, and it is one of the fastest routes to admin. - Object IDs without ownership checks.
GET /api/projects/project_1043returns the project to anyone authenticated. Swap the ID, get another user's data. - Tenant boundary gaps. Checks that confirm the user is an admin but not an admin of this tenant, so an admin in
org_12can manageorg_77. - Inconsistent enforcement. The REST endpoint checks the role, but the bulk export, the CSV import, the websocket handler, or the older
/v1/route does not. - Trusted headers. Access decided by
X-Forwarded-For,X-User-Role, or an internal-only header that an external caller can also set.
How do you test for privilege escalation?
Test every action with every role, and compare against what each role should be allowed to do. This is systematic work that needs authenticated test accounts at every level, ideally two per role in two separate tenants.
- Create the accounts. For each role (for example viewer, member, admin, owner), create
user_aanduser_binorg_12, plus the same set inorg_77. - Capture the high-privilege traffic. As the owner, exercise every feature and record each request: method, path, body, and which object IDs it references.
- Build the matrix. List each action as a row and each role as a column, and fill in the expected result from the product's permission documentation or the team's answer.
- Replay with each lower role. Swap in the lower role's session token and resend every captured request unchanged. Then resend with object IDs belonging to another user in the same tenant, then another tenant.
- Tamper with role-bearing input. Add
role,is_admin,org_id, andplanfields to create and update requests, and try changing your own membership. - Record the difference. Any response that succeeds where the matrix says it should not is a finding. Watch for
200responses that return data, and for403s that still perform the action.
A filled-in matrix for a small project API:
| Action | Viewer | Member | Admin | Other-tenant admin |
|---|---|---|---|---|
| GET project | allow | allow | allow | deny |
| PATCH project name | deny | allow | allow | deny |
| DELETE project | deny | deny | allow | deny |
| Invite member | deny | deny | allow | deny |
| Change member role | deny | deny | allow | deny |
| Export all projects | deny | deny | allow | deny |
The finding is any cell where the observed result is allow and the expected result is deny. The last column catches the tenant-boundary bugs that single-tenant testing misses. Tools such as Burp's Autorize extension automate the replay step, but building the expected matrix is manual work.
How do you fix it?
Deny by default and enforce authorization on the server, per request, with the object and the action both in the check. Centralize the decision so every route, including the old ones, goes through the same code.
In Django REST Framework, scope the queryset to the caller's tenant and gate each action on role:
from rest_framework import permissions, viewsets
class IsOrgAdminForWrites(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
membership = obj.org.memberships.filter(user=request.user).first()
if membership is None:
return False # other tenant: deny
if request.method in permissions.SAFE_METHODS:
return True
if view.action == "destroy":
return membership.role == "admin"
return membership.role in ("member", "admin")
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
permission_classes = [permissions.IsAuthenticated, IsOrgAdminForWrites]
def get_queryset(self):
# Horizontal control: only objects in the caller's orgs are reachable
return Project.objects.filter(org__memberships__user=self.request.user)And in the serializer, mark role, org and similar fields read-only so they cannot be set from the request body.
What does not work: hiding buttons or routes in the frontend, unguessable IDs such as UUIDs on their own (they leak through URLs, logs, and other responses), and a WAF, which sees a well-formed request from a valid user. Keep the role-by-action matrix as an automated test suite so a new endpoint without a check fails in CI.
What about OS and cloud privilege escalation?
The same idea applies below the application: an attacker with a foothold turns limited access into higher access. MITRE ATT&CK tracks it as its own tactic, TA0004.
- Operating systems. Misconfigured
sudorules, writable scripts run by root, SUID binaries, services running as SYSTEM or root with weak file permissions, and unpatched kernel flaws. - Cloud. Over-broad IAM permissions let an identity grant itself more. AWS documents the classic case with
iam:PassRole: a user who can pass a role to a service can make that service act with the role's permissions, even ones the user lacks. From inside a workload, the instance metadata service hands the instance role's credentials to anything that can make a request, which is why an application bug such as SSRF often becomes cloud privilege escalation.
[ Sources ]
- OWASP Top 10 2021: A01 Broken Access Control
- OWASP Web Security Testing Guide: Testing for Privilege Escalation
- OWASP API Security Top 10 2023: API5 Broken Function Level Authorization
- CWE-269: Improper Privilege Management
- MITRE ATT&CK: Privilege Escalation (TA0004)
- AWS IAM docs: Grant a user permissions to pass a role to an AWS service
Written by Parameter · Last reviewed

