Privilege Escalation (application-level)
Also known as: Privilege escalation, Vertical privilege escalation, Horizontal privilege escalation, Broken function-level authorization, Broken access control
A broken access control flaw in which a normal user reaches an administrative action, or acts on another user's resources, because the server relies on a hidden button or a client-supplied role instead of checking permission on every request.
How it works
Application-level privilege escalation is a broken access control flaw in which a caller obtains capabilities beyond those assigned to their account. Vertical escalation means a low-privilege user reaches a function meant for a higher role, such as an administrative action. Horizontal escalation means a user acts on resources that belong to a peer, such as another account's settings. Both come from the same gap: the server does not decide, on each request, whether this caller may perform this action.
The most common cause is that the check lives in the wrong place. A developer hides the admin button from normal users and considers the feature protected, but the endpoint behind it still accepts any signed-in caller. A close cousin is trusting data the client supplies, such as a role field in a request body, a cookie, or a hidden form value, to decide what the caller is allowed to do. Anything the client can send, the client can change.
These flaws survive testing because the application behaves correctly for the intended path. Normal users never see the admin screen, so nobody tries the call directly. The evidence shows up in logs: accounts that have no administrative role calling administrative routes, requests carrying role or flag fields that do not match the stored account, and either an unusual number of 403 responses or, worse, none at all on privileged routes.
The defence is to make authorization a server-side decision on every request. Resolve the caller's role and permissions from trusted server state, check them before the action runs, refuse by default when no rule grants access, and centralise the decision so no handler can forget it. Sensitive actions should be re-verified at the moment they are performed, for example with step-up authentication, rather than trusting an earlier check.
Walk through it
- 1Review the handler
- 2Confirm the finding
- 3Spot the untrusted input
- Write the enforcing handler
- Verify and monitor
A pre-release review flags the endpoint that promotes an account to administrator. The interface hides the button from non-admins. Read the handler as a reviewer and note what the server itself checks.
1// UI: <AdminButton visible={user.role === 'admin'} />2 3// POST /api/admin/users/:id/role4router.post("/api/admin/users/:id/role", requireSession, async (req, res) => {5 await db.users.update(req.params.id, { role: req.body.role });6 res.json({ ok: true });7});Spot it
- Accounts with no administrative role calling administrative routes such as /api/admin/*.
- Requests carrying role, isAdmin or similar fields whose values differ from the stored account.
- A privileged route that has never returned a 403 or 401, which suggests that no check is made.
- A spike of 403 responses from one session against many privileged routes, suggesting someone probing.
- An account whose role changed with no matching approved change request or administrator session.
Application access log
ts=2026-10-11T09:14:02Z user=u_204 role=agent method=POST path=/api/admin/users/204/role status=200
ts=2026-10-11T09:14:05Z user=u_204 role=agent method=GET path=/api/admin/export status=200
ts=2026-10-11T09:14:06Z user=u_204 role=agent method=POST path=/api/admin/settings status=200Audit log
ts=2026-10-11T09:14:02Z event=role_change target=u_204 old=agent new=admin actor=u_204 approval=nonesplSplunk: non-admin roles succeeding on admin routes
index=app sourcetype=access path="/api/admin/*" status=200
| where role!="admin"
| stats count dc(path) as routes by user roleAllow-list service accounts and support roles that legitimately use these routes before alerting.
sqlSQL: role changes made by the account being changed
SELECT ts, actor_id, target_id, old_role, new_role
FROM audit_log
WHERE event = 'role_change'
AND actor_id = target_id
AND ts > NOW(), INTERVAL '1 day';Stop it
Enforce permission checks on the server for every request
Decide on the server whether the authenticated caller may perform this action, on every route and every verb. A hidden button or a disabled control is a usability feature, not a control.
Deny by default and never trust client-sent roles or flags
Refuse unless a rule explicitly grants access. Resolve roles and permissions from trusted server state, such as the session or the account record, and ignore any role, flag or privilege field in the request.
Centralise access control and re-verify sensitive actions
Put the decision in shared middleware or a policy layer so no handler can omit it, scope resources to their owner to stop horizontal escalation, and require step-up authentication for high-impact actions.
UI-only check versus server-side enforcement
Vulnerable
// UI hides the button; server trusts any session
router.post("/api/admin/users/:id/role", requireSession, async (req, res) => {
await db.users.update(req.params.id, { role: req.body.role });
res.json({ ok: true });
});Hardened
router.post(
"/api/admin/users/:id/role",
requireSession,
requirePermission("users:change-role"), // server-side, deny by default
async (req, res) => {
const allowed = ["agent", "supervisor"]; // never accept arbitrary roles
if (!allowed.includes(req.body.role)) return res.status(400).end();
await db.users.update(req.params.id, { role: req.body.role });
audit("role_change", req.session.user.id, req.params.id);
res.json({ ok: true });
}
);requirePermission reads the caller's role from the server-side session, never from the request. Use your framework's policy layer where one exists.
- Check authorization server-side on every route and verb, including routes the interface never links to.
- Deny by default and grant access through explicit role, permission or ownership rules.
- Ignore role, isAdmin and similar fields in request bodies, cookies and hidden form values.
- Centralise checks in middleware or a policy engine and require a test per privileged route.
- Require step-up authentication or approval for role changes and other high-impact actions.
- Log every denial and every privilege change, and alert on non-admin callers reaching admin routes.
If it already happened
Deploy the server-side check or disable the affected administrative routes, and revoke or reset any role assignments made without approval.
Search access and audit logs for non-admin accounts that reached privileged routes or changed roles, and list the actions and records involved.
Restore correct roles from a trusted source, rotate sessions for affected accounts, and confirm the fix with a normal-user test against every privileged route.
Move authorization into shared middleware, add a checklist item for every new privileged route, and keep the log detections above.
Check yourself
1. A normal user can call an admin-only endpoint directly, although the interface hides the button. What is the root cause?
2. Which fix addresses both vertical and horizontal privilege escalation most directly?
3. Which log pattern most strongly suggests this flaw is being abused?