Permission Boundary Testing: Privilege Escalation and Least-Privilege Verification
Privilege escalation occurs when a user gains access or capabilities beyond what they were granted. Vertical escalation is gaining a higher role (user → admin); horizontal escalation is accessing another user's resources at the same privilege level. Both are critical security vulnerabilities that automated testing must catch.
This guide covers testing privilege escalation prevention: vertical escalation vectors, horizontal escalation via IDOR, least-privilege verification, and automated permission matrix testing.
Types of Privilege Escalation
Vertical escalation: User performs admin actions without having admin role.
Horizontal escalation: User A accesses User B's data (same role, different scope).
Temporary escalation abuse: User uses a time-limited elevated token after it should expire.
Parameter tampering: User sends ?admin=true or modifies request body to claim elevated privileges.
Mass assignment: User sends extra fields that update role or permissions directly.
Testing Vertical Privilege Escalation
// tests/security/vertical-escalation.test.ts
import request from 'supertest';
import app from '~/app';
describe('Vertical privilege escalation prevention', () => {
let userToken: string;
let editorToken: string;
let adminToken: string;
beforeAll(async () => {
userToken = await loginAs('user@example.com', 'user-role');
editorToken = await loginAs('editor@example.com', 'editor-role');
adminToken = await loginAs('admin@example.com', 'admin-role');
});
// Admin-only actions that lower-privileged users must not perform
const adminOnlyEndpoints = [
{ method: 'GET', path: '/api/admin/users', description: 'list all users' },
{ method: 'POST', path: '/api/admin/users', description: 'create user' },
{ method: 'DELETE', path: '/api/admin/users/1', description: 'delete user' },
{ method: 'PUT', path: '/api/admin/settings', description: 'update system settings' },
{ method: 'GET', path: '/api/admin/audit-log', description: 'view audit log' },
{ method: 'POST', path: '/api/admin/impersonate/1', description: 'impersonate user' },
];
for (const endpoint of adminOnlyEndpoints) {
it(`user cannot ${endpoint.description}`, async () => {
const res = await request(app)
[endpoint.method.toLowerCase()](endpoint.path)
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(403);
});
it(`editor cannot ${endpoint.description}`, async () => {
const res = await request(app)
[endpoint.method.toLowerCase()](endpoint.path)
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(403);
});
it(`admin CAN ${endpoint.description}`, async () => {
const res = await request(app)
[endpoint.method.toLowerCase()](endpoint.path)
.set('Authorization', `Bearer ${adminToken}`);
// Admin should get 200 or 201, not 403
expect(res.status).not.toBe(403);
});
}
});Testing Role Self-Assignment
A common vulnerability: can a user update their own role?
describe('Role self-assignment prevention', () => {
it('user cannot update their own role to admin', async () => {
const res = await request(app)
.patch('/api/users/me')
.set('Authorization', `Bearer ${userToken}`)
.send({ role: 'admin' });
// Either reject the request or silently ignore the role field
if (res.status === 200) {
// If the update succeeded, verify role was NOT changed
const profileRes = await request(app)
.get('/api/users/me')
.set('Authorization', `Bearer ${userToken}`);
expect(profileRes.body.role).toBe('user'); // Not 'admin'
} else {
expect(res.status).toBeOneOf([400, 403, 422]);
}
});
it('editor cannot grant admin access to another user', async () => {
const res = await request(app)
.patch('/api/users/other-user-id')
.set('Authorization', `Bearer ${editorToken}`)
.send({ role: 'admin' });
expect(res.status).toBe(403);
});
it('admin cannot self-escalate to super_admin', async () => {
const res = await request(app)
.patch('/api/users/me')
.set('Authorization', `Bearer ${adminToken}`)
.send({ role: 'super_admin' });
if (res.status === 200) {
const profile = await getProfile(adminToken);
expect(profile.role).toBe('admin'); // Unchanged
} else {
expect(res.status).toBeOneOf([400, 403, 422]);
}
});
});Testing Parameter Tampering
describe('Parameter tampering prevention', () => {
it('ignores admin query parameter', async () => {
const res = await request(app)
.get('/api/admin/users?admin=true&role=admin')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(403); // admin=true in query ignored
});
it('ignores elevated role in request body', async () => {
const res = await request(app)
.post('/api/documents')
.set('Authorization', `Bearer ${userToken}`)
.send({
title: 'Test Document',
authorRole: 'admin', // Attempted privilege claim
permissions: ['delete', 'admin'],
});
if (res.status === 201) {
// Document was created — verify it doesn't have elevated permissions
expect(res.body.authorRole).not.toBe('admin');
expect(res.body.permissions).not.toContain('admin');
}
});
it('does not trust user-supplied tenant_id', async () => {
// User provides a different tenant's ID in the request body
const res = await request(app)
.post('/api/documents')
.set('Authorization', `Bearer ${userToken}`)
.send({
title: 'Cross-tenant document',
tenantId: 'other-tenant-id', // Attempted cross-tenant injection
});
if (res.status === 201) {
// Document must be in the authenticated user's tenant, not the supplied one
const doc = await getDocument(adminToken, res.body.id);
expect(doc.tenantId).not.toBe('other-tenant-id');
}
});
});Testing Mass Assignment Prevention
describe('Mass assignment prevention', () => {
it('cannot mass-assign protected fields on user creation', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${adminToken}`)
.send({
email: 'new-user@example.com',
password: 'password123',
// Protected fields that should be ignored:
role: 'super_admin',
isVerified: true,
createdAt: '2020-01-01',
passwordHash: '$2b$10$fakehash',
});
expect(res.status).toBeOneOf([200, 201]);
// Verify protected fields were not set
const user = res.body;
expect(user.role).not.toBe('super_admin');
expect(user.isVerified).toBe(false); // Should require email verification
expect(user.passwordHash).toBeUndefined(); // Never expose in response
});
it('cannot mass-assign subscription status', async () => {
const res = await request(app)
.put('/api/users/me/profile')
.set('Authorization', `Bearer ${userToken}`)
.send({
displayName: 'Alice',
subscriptionStatus: 'active', // Should be ignored
plan: 'enterprise', // Should be ignored
creditsRemaining: 99999, // Should be ignored
});
expect(res.status).toBeOneOf([200, 204]);
const profile = await getProfile(userToken);
expect(profile.subscriptionStatus).not.toBe('active');
expect(profile.plan).not.toBe('enterprise');
expect(profile.creditsRemaining).not.toBe(99999);
});
});Least-Privilege Verification: Testing Minimal Access
Verify that roles only have the permissions they need — not more:
describe('Least-privilege verification', () => {
// Editor should have these permissions
const EDITOR_ALLOWED = [
'GET /api/documents',
'POST /api/documents',
'PUT /api/documents/1',
'GET /api/users/me',
];
// Editor should NOT have these
const EDITOR_DENIED = [
'DELETE /api/documents/1',
'GET /api/admin/users',
'DELETE /api/users/1',
'PUT /api/admin/settings',
];
for (const endpoint of EDITOR_DENIED) {
const [method, path] = endpoint.split(' ');
it(`editor is denied: ${method} ${path}`, async () => {
const res = await request(app)
[method.toLowerCase()](path)
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(403);
});
}
// Verify editor isn't over-permissioned — they don't have DELETE even though they can PUT
it('editor cannot delete resources they can update', async () => {
const createRes = await request(app)
.post('/api/documents')
.set('Authorization', `Bearer ${editorToken}`)
.send({ title: 'Test Doc' });
expect(createRes.status).toBe(201);
const deleteRes = await request(app)
.delete(`/api/documents/${createRes.body.id}`)
.set('Authorization', `Bearer ${editorToken}`);
expect(deleteRes.status).toBe(403);
});
});Automated Escalation Matrix
Generate comprehensive escalation tests from your permission model:
import { ROLES, PERMISSIONS, hasPermission } from '~/auth/permissions';
const API_MAP: Record<string, { method: string; path: string }> = {
'content:delete': { method: 'DELETE', path: '/api/content/test-id' },
'users:update-role': { method: 'PATCH', path: '/api/users/test-id/role' },
'billing:modify': { method: 'POST', path: '/api/billing/refund' },
};
describe('Privilege escalation matrix', () => {
const roles = Object.values(ROLES);
const permissions = Object.keys(API_MAP) as (keyof typeof API_MAP)[];
for (const permission of permissions) {
const endpoint = API_MAP[permission];
for (const role of roles) {
const shouldAllow = hasPermission(role as any, permission as any);
it(`${role} → ${permission}: ${shouldAllow ? 'ALLOWED' : 'DENIED'}`, async () => {
const token = role === 'anonymous' ? null : await getTokenForRole(role);
const req = request(app)[endpoint.method.toLowerCase()](endpoint.path);
if (token) req.set('Authorization', `Bearer ${token}`);
const res = await req;
if (shouldAllow) {
expect(res.status).not.toBe(403);
} else if (!token) {
expect(res.status).toBe(401);
} else {
expect(res.status).toBe(403);
}
});
}
}
});Summary
Privilege escalation testing:
- Test vertical escalation — users cannot perform admin actions by trying admin endpoints
- Test role self-assignment — users cannot update their own role through the API
- Test parameter tampering —
?admin=true,role: 'admin'in body must be ignored - Test mass assignment — protected fields (role, subscriptionStatus) must not be settable via bulk updates
- Verify least privilege — roles have only the permissions they need, no more
- Generate escalation matrices — systematic coverage of all role × permission combinations
Privilege escalation tests are security tests, not functionality tests. Run them on every PR that touches authorization code, and in nightly security scans against production-equivalent environments.