Back to Blog

Broken Access Control: The Number 1 OWASP Risk Hiding in Your API Right Now

Broken access control is the #1 web application vulnerability (OWASP 2021 & 2025). It occurs when authenticated users can access resources or perform actions beyond their intended permissions. Here's how to systematically test and prevent it.

Published

7 min read

Reading time

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

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.

Related Posts