Broken Access Control: The Number 1 OWASP Risk Hiding in Your API Right Now
In the 2021 and 2025 versions of the OWASP Top 10, Broken Access Control sits at position #1. It is the most prevalent, most impactful, and arguably most preventable category of web application vulnerability.
The reason it tops the list is not that it requires sophisticated exploitation — it often requires nothing more than changing an ID in a URL. The reason it is so prevalent is that access control is the kind of thing that works perfectly in development (where a single developer is testing their own resources) and fails in production (where users discover they can access each other's data simply by guessing IDs).
This guide builds a systematic access control test suite that catches the most critical patterns before they reach production.
The Broken Access Control Taxonomy
Access control failures come in several distinct forms:
mindmap
root((Broken Access Control))
IDOR
Access other users' objects
by changing ID in request
Privilege Escalation
Vertical: Regular user accesses admin
Horizontal: User A accesses User B's data
Function-Level Access
Unpublished admin endpoints accessible
HTTP method bypasses (GET instead of POST)
JWT / Token Claims
Tampered role claim in JWT
Missing server-side authorization check
Path Traversal
../../../etc/passwd
Escaping authorized scope via path
Missing Service-to-Service Auth
Internal APIs accessible without auth
SSRF to reach internal endpoints
IDOR (Insecure Direct Object Reference) Testing
IDOR is the most common broken access control pattern. A user accesses another user's resource by modifying an identifier:
// tests/security/idor.spec.ts
import { test, expect } from '@playwright/test';
test("user cannot access another user's project", async ({ browser }) => {
// Setup: Create a project as User A
const userAContext = await browser.newContext({
storageState: 'playwright/.auth/user-a.json',
});
const userARequest = (await userAContext.newPage()).request;
const createResponse = await userARequest.post('/api/projects', {
data: { name: 'User A Private Project' },
});
const { id: projectId } = await createResponse.json();
// Close User A's context
await userAContext.close();
// Attempt: User B tries to access User A's project
const userBContext = await browser.newContext({
storageState: 'playwright/.auth/user-b.json',
});
const userBPage = await userBContext.newPage();
const readResponse = await userBPage.request.get(`/api/projects/${projectId}`);
expect(readResponse.status()).toBe(403); // Forbidden, not 200
const updateResponse = await userBPage.request.put(`/api/projects/${projectId}`, {
data: { name: 'Hacked!' },
});
expect(updateResponse.status()).toBe(403);
const deleteResponse = await userBPage.request.delete(`/api/projects/${projectId}`);
expect(deleteResponse.status()).toBe(403);
await userBContext.close();
});
This test pattern — create a resource as User A, attempt all CRUD operations as User B — should be applied to every resource type in your application.
IDOR in Sequential vs. UUID-Based IDs
Sequential numeric IDs (/api/orders/1001, /api/orders/1002) are more vulnerable because they are discoverable. UUIDs (/api/orders/550e8400-e29b-41d4-a716-446655440000) are harder to guess but still exploitable — the security must come from authorization checks, not ID obscurity.
Test both:
test("predictable integer IDs do not grant access to others' resources", async ({ request }) => {
// Try sequential IDs around a known legitimate ID
const knownUserAItemId = 1050;
const idsToTry = [knownUserAItemId - 1, knownUserAItemId, knownUserAItemId + 1, 1, 2, 3, 999, 1000, 99999];
const results = await Promise.all(
idsToTry.map((id) =>
request.get(`/api/items/${id}`, {
headers: { Authorization: `Bearer ${USER_B_TOKEN}` },
}),
),
);
// User B should only successfully access their own items
results.forEach((response, i) => {
if (idsToTry[i] === USER_B_ITEM_ID) {
expect(response.status()).toBe(200); // Their own item
} else {
expect([403, 404]).toContain(response.status()); // Others' items
}
});
});
Vertical Privilege Escalation Testing
Vertical escalation: a regular user accessing admin-level functionality.
test('regular user cannot access admin API endpoints', async ({ request }) => {
const regularUserToken = process.env.TEST_REGULAR_USER_TOKEN!;
const adminEndpoints = [
{ method: 'GET', path: '/api/admin/users' },
{ method: 'GET', path: '/api/admin/billing/all' },
{ method: 'DELETE', path: '/api/admin/users/user-999' },
{ method: 'POST', path: '/api/admin/impersonate' },
{ method: 'GET', path: '/api/admin/metrics' },
{ method: 'POST', path: '/api/admin/broadcast-email' },
];
for (const endpoint of adminEndpoints) {
const response = await request.fetch(endpoint.path, {
method: endpoint.method,
headers: { Authorization: `Bearer ${regularUserToken}` },
});
// Must return 403, not 200
expect(response.status()).toBe(403);
}
});
test('regular user cannot escalate to admin via role claim manipulation', async ({ request }) => {
// Create a JWT with manipulated role claim
const regularUserJWT = process.env.TEST_REGULAR_USER_JWT!;
// Decode and modify the claim (without valid signature - this should fail)
const [header, payload] = regularUserJWT.split('.');
const decodedPayload = JSON.parse(Buffer.from(payload, 'base64').toString());
decodedPayload.role = 'admin'; // Privilege escalation attempt
const tamperedToken = `${header}.${Buffer.from(JSON.stringify(decodedPayload)).toString('base64')}.invalid-signature`;
const response = await request.get('/api/admin/users', {
headers: { Authorization: `Bearer ${tamperedToken}` },
});
// Must reject token with invalid signature
expect(response.status()).toBe(401);
});
Horizontal Privilege Escalation: Organization/Team Scoping
In multi-tenant or team-based applications, horizontal escalation means accessing another team's resources while authenticated:
test('user cannot access resources from a different organization', async ({ request }) => {
// User belongs to Org A
const response = await request.get('/api/scans?org_id=org-b-id', {
headers: { Authorization: `Bearer ${ORG_A_USER_TOKEN}` },
});
// Either 403 or 200 with empty/correct results (no Org B data)
if (response.status() === 200) {
const data = await response.json();
data.scans.forEach((scan: any) => {
expect(scan.org_id).toBe('org-a-id'); // Must only contain Org A data
expect(scan.org_id).not.toBe('org-b-id');
});
} else {
expect(response.status()).toBe(403);
}
});
HTTP Method Override Vulnerabilities
Some implementations protect DELETE /api/resource/:id but not POST /api/resource/:id?_method=DELETE. Test HTTP method bypasses:
test('HTTP method override does not bypass access controls', async ({ request }) => {
const restrictedResourceId = 'resource-owned-by-admin';
// Standard DELETE is protected
const deleteResponse = await request.delete(`/api/resources/${restrictedResourceId}`, {
headers: { Authorization: `Bearer ${REGULAR_USER_TOKEN}` },
});
expect(deleteResponse.status()).toBe(403);
// Method override attempts should also fail
const methodOverrideAttempts = [
request.post(`/api/resources/${restrictedResourceId}?_method=DELETE`, {
headers: { Authorization: `Bearer ${REGULAR_USER_TOKEN}` },
}),
request.post(`/api/resources/${restrictedResourceId}`, {
headers: {
Authorization: `Bearer ${REGULAR_USER_TOKEN}`,
'X-HTTP-Method-Override': 'DELETE',
},
}),
];
const results = await Promise.all(methodOverrideAttempts);
results.forEach((r) => expect([403, 405]).toContain(r.status()));
});
Building an Automated Access Control Test Matrix
For comprehensive coverage, build a test matrix covering all resource types × user roles × CRUD operations:
| Resource | Anonymous | Free User | Pro User | Admin |
|---|---|---|---|---|
| GET /api/projects | 401 | Own only | Own only | All |
| POST /api/projects | 401 | ✅ (limit: 5) | ✅ (limit: 100) | ✅ |
| DELETE /api/projects/:id | 401 | Own only | Own only | All |
| GET /api/admin/users | 401 | 403 | 403 | ✅ |
| POST /api/billing/cancel | 401 | Own only | Own only | All |
Automate this matrix with parameterized tests:
const accessMatrix = [
{ resource: '/api/projects', method: 'GET', role: 'anonymous', expected: 401 },
{ resource: '/api/projects', method: 'GET', role: 'free_user', expected: 200 },
{ resource: '/api/admin/users', method: 'GET', role: 'free_user', expected: 403 },
{ resource: '/api/admin/users', method: 'GET', role: 'admin', expected: 200 },
// ... full matrix
];
for (const scenario of accessMatrix) {
test(`${scenario.role} ${scenario.method} ${scenario.resource} → ${scenario.expected}`, async ({ request }) => {
const token = getTokenForRole(scenario.role);
const response = await request.fetch(scenario.resource, {
method: scenario.method,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
expect(response.status()).toBe(scenario.expected);
});
}
Related articles: Also see IDOR vulnerabilities as the most common form of broken access control, OWASPs full taxonomy of vulnerabilities to test alongside access control, and securing the API layer where access control failures most often appear.
Integration with Monitoring
Access control failures in production are often discovered by attackers before your team. A user who notices they can access /api/projects/1 through /api/projects/100 is exploring a vulnerability. Monitoring for unusual resource access patterns — many 403 responses from a single IP, or access patterns across non-sequential IDs — is the detection layer that complements your test suite.
Your security test suite and our guide on OWASP Top 10 QA practices together form a comprehensive access control defense strategy.
Further Reading
- OWASP Top 10 A01: Broken Access Control: OWASP's authoritative explanation of broken access control, common weaknesses, and prevention techniques
- OWASP Testing Guide — Authorization Testing: Step-by-step methodology for testing IDOR, privilege escalation, and horizontal access control
- OWASP ASVS V4 — Access Control Requirements: The verification standard for building and auditing access control in web applications
- CWE-284 — Improper Access Control: MITRE's Common Weakness Enumeration entry for access control failures with technical examples
Monitor your API for unexpected access patterns: Try ScanlyApp free and add continuous scanning of your authenticated API endpoints to catch access control regressions in production.
