Privilege Escalation: Code Review Guide
Table of Contents
Introduction
Privilege escalation occurs when an attacker gains access to resources or actions beyond what their authenticated identity should permit. It is one of the most impactful vulnerability classes because a successful exploit directly translates into unauthorized data access, account takeover, or full system compromise.
Unlike injection attacks that exploit input handling, privilege escalation exploits broken authorization logic — the code that decides "is this user allowed to do this?" When that logic is missing, incomplete, or bypassable, attackers can elevate their access without needing any special payload.
Why This Matters
Broken Access Control has been the #1 vulnerability in the OWASP Top 10 since 2021. In 94% of tested applications, some form of broken access control was detected. Privilege escalation is the most dangerous manifestation of this — it turns any authenticated user into a potential admin.
- Vertical escalation — A regular user performs admin-level actions (e.g., deleting accounts, changing system config)
- Horizontal escalation — A user accesses another user's data or performs actions on their behalf
- Role manipulation — Tampering with tokens, cookies, or parameters to change the user's assigned role
- Context escalation — Crossing tenant, organization, or environment boundaries
Types of Privilege Escalation
Understanding the different types of privilege escalation is essential for targeted code review. Each type has distinct code patterns and requires different defensive strategies.
Privilege Escalation Attack Types
{"role": "user"} → {"role": "admin"}Tampering with role identifiers in tokens, parameters, or storage/api/tenant/A/settings → /api/tenant/B/settingsCrossing tenant, organization, or environment boundariesKey Insight: Privilege escalation is not a single vulnerability — it is the outcome of broken authorization logic. Every endpoint that performs an action or returns data must verify the caller has the right level of access.
Vertical privilege escalation is the most severe form. It occurs when a low-privilege user can perform actions reserved for higher-privilege roles. The typical root cause is missing function-level access control — endpoints that check authentication but not authorization.
Horizontal privilege escalation is more subtle and often harder to detect during code review. It occurs when a user can access or modify resources belonging to another user at the same privilege level. The root cause is usually missing resource ownership validation.
A user with the 'editor' role can access the endpoint GET /api/admin/audit-logs, which should be restricted to 'admin' only. What type of privilege escalation is this?
Vertical Escalation Patterns
Vertical privilege escalation vulnerabilities share a common pattern: the server verifies who the user is but not what they're allowed to do. During code review, look for endpoints that check authentication without checking authorization.
Vertical Privilege Escalation Flow
POST /api/admin/usersRegular user tokenChecks: isAuthenticated?No role check!200 OK — admin action performedFull privilege escalationRoot Cause: The endpoint verifies the user is logged in (authentication) but never checks whether they have the admin role (authorization). This "missing function-level access control" is OWASP API Security Top 10 #5.
Vulnerable: Admin endpoint with authentication but no authorization
1// Only checks if user is logged in — any authenticated user can call this
2router.delete('/api/admin/users/:userId', authenticate, async (req, res) => {
3 const { userId } = req.params;
4 await User.findByIdAndDelete(userId);
5 res.json({ message: 'User deleted' });
6});
7
8// Same pattern — missing role check on sensitive operation
9router.post('/api/admin/config', authenticate, async (req, res) => {
10 await SystemConfig.updateOne({}, req.body);
11 res.json({ message: 'Configuration updated' });
12});Secure: Role-based middleware enforcing authorization
1function requireRole(...roles: string[]) {
2 return (req: Request, res: Response, next: NextFunction) => {
3 if (!req.user) {
4 return res.status(401).json({ error: 'Authentication required' });
5 }
6
7 if (!roles.includes(req.user.role)) {
8 logger.warn('Privilege escalation attempt', {
9 userId: req.user.id,
10 userRole: req.user.role,
11 requiredRoles: roles,
12 endpoint: req.originalUrl,
13 ip: req.ip,
14 });
15 return res.status(403).json({ error: 'Insufficient permissions' });
16 }
17
18 next();
19 };
20}
21
22router.delete('/api/admin/users/:userId',
23 authenticate,
24 requireRole('admin'),
25 async (req, res) => {
26 await User.findByIdAndDelete(req.params.userId);
27 res.json({ message: 'User deleted' });
28 }
29);Another common vertical escalation vector is forced browsing — directly accessing admin URLs that are hidden from the UI but still functional on the server.
Vulnerable: Relying on UI hiding instead of server-side checks
1// Frontend "hides" the admin panel from non-admin users
2function Navigation({ user }) {
3 return (
4 <nav>
5 <Link href="/dashboard">Dashboard</Link>
6 {/* Hidden from UI, but the route still works for anyone! */}
7 {user.role === 'admin' && <Link href="/admin">Admin Panel</Link>}
8 </nav>
9 );
10}
11
12// Backend: no role verification at all
13app.get('/admin', authenticate, (req, res) => {
14 // Any logged-in user who navigates to /admin gets the admin panel
15 res.render('admin-panel', { users: await User.find() });
16});Code Review Red Flag
If you see admin/privileged routes using only authenticate middleware without a role or permission check, flag it immediately. Client-side UI hiding is never a security control — every sensitive action must be authorized on the server.
You're reviewing a Node.js API and notice that all /api/admin/* routes use the `authenticate` middleware, but only 2 out of 15 routes also use `requireRole('admin')`. What should you recommend?
Horizontal Escalation Patterns
Horizontal privilege escalation occurs when a user accesses resources belonging to another user at the same privilege level. This is closely related to Insecure Direct Object Reference (IDOR) but extends beyond simple ID manipulation to include any form of cross-user access.
Vulnerable: User-controlled ID without ownership check
1// User A can view User B's private documents by changing the ID
2router.get('/api/documents/:documentId', authenticate, async (req, res) => {
3 const doc = await Document.findById(req.params.documentId);
4 if (!doc) return res.status(404).json({ error: 'Not found' });
5
6 // Missing: Does req.user own this document?
7 res.json(doc);
8});
9
10// User A can update User B's profile
11router.put('/api/users/:userId/profile', authenticate, async (req, res) => {
12 // Missing: Is req.user.id === req.params.userId?
13 await User.findByIdAndUpdate(req.params.userId, req.body);
14 res.json({ message: 'Profile updated' });
15});Secure: Ownership verification on every resource access
1router.get('/api/documents/:documentId', authenticate, async (req, res) => {
2 const doc = await Document.findOne({
3 _id: req.params.documentId,
4 $or: [
5 { ownerId: req.user.id },
6 { sharedWith: req.user.id },
7 { organizationId: req.user.organizationId, visibility: 'org' },
8 ],
9 });
10
11 if (!doc) {
12 return res.status(404).json({ error: 'Not found' });
13 }
14
15 res.json(doc);
16});
17
18router.put('/api/users/:userId/profile', authenticate, async (req, res) => {
19 if (req.user.id !== req.params.userId && req.user.role !== 'admin') {
20 return res.status(403).json({ error: 'Cannot modify another user\'s profile' });
21 }
22
23 const allowedFields = ['displayName', 'avatar', 'bio'];
24 const updates = pick(req.body, allowedFields);
25 await User.findByIdAndUpdate(req.params.userId, updates);
26 res.json({ message: 'Profile updated' });
27});A subtler form of horizontal escalation occurs through parameter pollution — injecting additional parameters that override the authenticated user's identity.
Vulnerable: Trusting user-supplied userId over the session
1@app.route('/api/orders', methods=['POST'])
2@require_auth
3def create_order():
4 data = request.get_json()
5 order = Order(
6 user_id=data['user_id'], # Attacker sets this to any user's ID!
7 items=data['items'],
8 total=calculate_total(data['items']),
9 )
10 db.session.add(order)
11 db.session.commit()
12 return jsonify(order.to_dict()), 201Secure: Always derive identity from the authenticated session
1@app.route('/api/orders', methods=['POST'])
2@require_auth
3def create_order():
4 data = request.get_json()
5 order = Order(
6 user_id=current_user.id, # Identity from session, never from input
7 items=data['items'],
8 total=calculate_total(data['items']),
9 )
10 db.session.add(order)
11 db.session.commit()
12 return jsonify(order.to_dict()), 201Review Pattern
Search for any code that reads a user ID, organization ID, or tenant ID from request parameters, query strings, or request bodies and uses it for authorization decisions. The authenticated identity should always come from the server-side session or verified token — never from user-controlled input.
An API endpoint GET /api/users/:id/settings returns user settings based on the URL parameter. The endpoint checks authentication but not ownership. A user changes :id from their own ID to another user's ID and gets their settings. What is the most effective fix?
Role & Permission Manipulation
Role manipulation attacks target the mechanism that assigns or evaluates permissions. Instead of bypassing access controls, the attacker changes their own privilege level by tampering with role identifiers stored in tokens, cookies, hidden fields, or request parameters.
Vulnerable: Role stored in client-side JWT without server validation
1// Registration endpoint lets user set their own role
2router.post('/api/register', async (req, res) => {
3 const { email, password, role } = req.body; // Role from user input!
4
5 const user = await User.create({ email, password, role });
6 const token = jwt.sign(
7 { userId: user.id, role: user.role }, // Role baked into JWT
8 JWT_SECRET
9 );
10 res.json({ token });
11});
12
13// Middleware trusts the role claim from the JWT blindly
14function authorize(requiredRole: string) {
15 return (req: Request, res: Response, next: NextFunction) => {
16 const decoded = jwt.verify(req.headers.authorization, JWT_SECRET);
17 if (decoded.role !== requiredRole) {
18 return res.status(403).json({ error: 'Forbidden' });
19 }
20 req.user = decoded;
21 next();
22 };
23}Secure: Server-side role assignment with database lookup
1router.post('/api/register', async (req, res) => {
2 const { email, password } = req.body;
3
4 const user = await User.create({
5 email,
6 password,
7 role: 'user', // Always assign default role server-side
8 });
9
10 const token = jwt.sign({ userId: user.id }, JWT_SECRET);
11 res.json({ token });
12});
13
14function authorize(requiredRole: string) {
15 return async (req: Request, res: Response, next: NextFunction) => {
16 const decoded = jwt.verify(req.headers.authorization, JWT_SECRET);
17
18 // Always fetch current role from database — not from the token
19 const user = await User.findById(decoded.userId).select('role');
20 if (!user || user.role !== requiredRole) {
21 return res.status(403).json({ error: 'Forbidden' });
22 }
23
24 req.user = { id: decoded.userId, role: user.role };
25 next();
26 };
27}Another common manipulation vector is mass assignment — when a framework automatically binds request body fields to model attributes, allowing attackers to set fields like role or isAdmin that should never come from user input.
Vulnerable: Mass assignment allows role elevation
1# Django view — direct model update from request data
2class UserProfileView(UpdateAPIView):
3 serializer_class = UserSerializer
4 queryset = User.objects.all()
5
6# The serializer exposes ALL fields including 'role' and 'is_staff'
7class UserSerializer(serializers.ModelSerializer):
8 class Meta:
9 model = User
10 fields = '__all__' # Attacker can POST {"role": "admin", "is_staff": true}Secure: Explicit field allowlist prevents unauthorized field modification
1class UserProfileSerializer(serializers.ModelSerializer):
2 class Meta:
3 model = User
4 fields = ['display_name', 'avatar', 'bio', 'timezone']
5 read_only_fields = ['email', 'role', 'is_staff', 'is_superuser', 'date_joined']
6
7class AdminUserSerializer(serializers.ModelSerializer):
8 """Only used by admin endpoints — never exposed to regular users."""
9 class Meta:
10 model = User
11 fields = ['display_name', 'avatar', 'bio', 'timezone', 'role', 'is_staff']Role Manipulation Attack Vectors
| Vector | Example | Prevention |
|---|---|---|
| Registration with role parameter | POST /register {"role": "admin"} | Ignore role from input, assign server-side default |
| JWT claim tampering | Modify role claim in JWT payload | Verify role from database on each request, not from token |
| Cookie-based role | Set-Cookie: role=admin | Never store authorization state in client-side cookies |
| Hidden form field | <input type="hidden" name="role" value="admin"> | Server-side role assignment only |
| Mass assignment | PUT /profile {"isAdmin": true} | Explicit field allowlists on all model bindings |
| GraphQL mutation | mutation { updateUser(role: ADMIN) } | Authorization directives on sensitive fields |
A user registration API accepts a JSON body with email, password, and role fields. The backend uses `User.create(req.body)` to create the account. What is the best fix?
Code Review Red Flags
During code review, these patterns reliably signal potential privilege escalation vulnerabilities. Use them as a checklist when reviewing any endpoint that handles sensitive data or actions.
Privilege Escalation Red Flags Checklist
| Pattern | What to Look For | Risk Level |
|---|---|---|
| Auth without authz | authenticate middleware without a role/permission check | Critical |
| User ID from input | user_id, userId, or ownerId read from req.body or req.params for authorization | Critical |
| Blanket field exposure | fields: '__all__' or SELECT * without field filtering | High |
| Client-side role storage | Role/permission stored in cookies, localStorage, or hidden fields | Critical |
| Inconsistent middleware | Some routes in a privileged group lack the permission check others have | High |
| Self-service role change | PUT /profile endpoint that accepts a role or permission field | Critical |
| Missing tenant scoping | Database queries without WHERE tenant_id = ? or organization filter | High |
| Direct object access | Resource fetched by ID alone without ownership validation | High |
Pattern: Inconsistent authorization across related endpoints. A common anti-pattern is protecting the "read" endpoint but not the "write" endpoint for the same resource, or protecting the UI-facing route but not the API route.
Vulnerable: GET is protected but POST/DELETE are not
1// GET is properly protected
2router.get('/api/admin/settings',
3 authenticate,
4 requireRole('admin'),
5 getSettings
6);
7
8// POST is missing the role check! Any authenticated user can modify settings.
9router.post('/api/admin/settings',
10 authenticate,
11 // requireRole('admin') — forgotten!
12 updateSettings
13);
14
15// DELETE is also unprotected
16router.delete('/api/admin/settings/:key',
17 authenticate,
18 // requireRole('admin') — forgotten!
19 deleteSetting
20);Secure: Router-level authorization covers all methods
1const adminRouter = express.Router();
2
3// All routes under /api/admin require authentication AND admin role
4adminRouter.use(authenticate);
5adminRouter.use(requireRole('admin'));
6
7adminRouter.get('/settings', getSettings);
8adminRouter.post('/settings', updateSettings);
9adminRouter.delete('/settings/:key', deleteSetting);
10adminRouter.get('/users', listUsers);
11adminRouter.post('/users/:id/ban', banUser);
12
13app.use('/api/admin', adminRouter);Pattern: Privilege check in the wrong layer. Authorization checks in the controller layer can be forgotten when new endpoints are added. Moving them to middleware or a data-access layer provides stronger guarantees.
Vulnerable: Authorization check buried in business logic
1# Some developers remember the check, others don't
2def delete_document(request, doc_id):
3 doc = Document.objects.get(id=doc_id)
4 if doc.owner_id != request.user.id: # Check exists here...
5 return HttpResponseForbidden()
6 doc.delete()
7 return JsonResponse({'status': 'deleted'})
8
9def archive_document(request, doc_id):
10 doc = Document.objects.get(id=doc_id)
11 # Oops — forgot the ownership check in this endpoint!
12 doc.archived = True
13 doc.save()
14 return JsonResponse({'status': 'archived'})Secure: Ownership enforced at the query level — impossible to forget
1class OwnedDocumentManager(models.Manager):
2 """Every query is automatically scoped to the requesting user."""
3 def for_user(self, user):
4 return self.filter(
5 models.Q(owner=user) |
6 models.Q(shared_with=user) |
7 models.Q(organization=user.organization, visibility='org')
8 )
9
10class Document(models.Model):
11 objects = OwnedDocumentManager()
12 # ...
13
14def delete_document(request, doc_id):
15 doc = Document.objects.for_user(request.user).get(id=doc_id) # 404 if not owned
16 doc.delete()
17 return JsonResponse({'status': 'deleted'})
18
19def archive_document(request, doc_id):
20 doc = Document.objects.for_user(request.user).get(id=doc_id) # Same protection
21 doc.archived = True
22 doc.save()
23 return JsonResponse({'status': 'archived'})Best Practice: Secure by Default
The safest authorization designs make it impossible to forget the access check. Scoped query builders, row-level security policies, and router-level middleware all achieve this by enforcing authorization before the developer writes any endpoint logic.