Why Broken Access Control Tops OWASP Since 2021
In the 2021 OWASP Top 10 update, broken access control moved from #5 to #1 — replacing injection at the top of the list for the first time. It's a position it deserved. OWASP found access control failures in 94% of tested applications. That's not a niche problem. It's endemic.
The reason it's so prevalent is that access control is almost impossible to get right by default. Unlike SQL injection (where parameterized queries are a universal fix), access control requires correct logic at every single endpoint — for every user role, every resource type, and every action. One missed check is a vulnerability.
BOLA vs IDOR: You'll see both terms. IDOR (Insecure Direct Object Reference) is the traditional OWASP term. BOLA (Broken Object Level Authorization) is the OWASP API Security Top 10 equivalent. They describe the same problem — trusting user-supplied IDs without verifying ownership.
IDOR / BOLA: How Changing a Number Breaks Security
IDOR is the clearest manifestation of broken access control. An application uses a user-controlled identifier (order ID, document ID, account number) to retrieve data — and doesn't check that the requesting user actually owns it.
# Vulnerable — no ownership check @app.get("/orders/{order_id}") def get_order(order_id: int, user=Depends(get_current_user)): order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(404) return order # returns ANY order, not just the user's # Fixed — ownership enforced in the query @app.get("/orders/{order_id}") def get_order(order_id: int, user=Depends(get_current_user)): order = db.query(Order).filter( Order.id == order_id, Order.user_id == user.id # ownership enforced here ).first() if not order: raise HTTPException(404) # same error — don't leak existence return order
An attacker who is logged in as user 42 can try GET /orders/1, GET /orders/2, etc. With sequential IDs, they can enumerate the entire orders table in a for loop. With UUIDs, it's harder but not impossible if they can predict or discover other IDs through other leaks.
Horizontal vs Vertical Privilege Escalation
These are two distinct access control failures with different impacts:
Horizontal Escalation
User A accesses User B's data. Same privilege level, different account. This is what IDOR typically enables — a customer viewing another customer's orders, a patient viewing another patient's records, a user modifying another user's profile.
Vertical Escalation
A regular user accesses admin functionality. Different privilege level. This happens when admin endpoints only check authentication (are you logged in?) but not authorisation (are you an admin?).
# Vertical escalation — checks login but not admin role @app.delete("/admin/users/{user_id}") def delete_user(user_id: int, user=Depends(get_current_user)): # user is authenticated but might not be admin db.query(User).filter(User.id == user_id).delete() # Fixed — role check required @app.delete("/admin/users/{user_id}") def delete_user(user_id: int, user=Depends(require_role("admin"))): db.query(User).filter(User.id == user_id).delete()
Real-World Breaches From Access Control Failures
Access control failures have caused some of the most damaging breaches in recent years:
- Optus (2022): An unauthenticated API endpoint exposed customer records. No authentication was required to retrieve the data. 9.8 million Australians affected.
- T-Mobile (2023): API didn't properly verify that the requesting customer matched the account being queried. Customer data accessible by changing account identifiers in requests.
- Healthcare provider (2024): Patient portal allowed accessing other patients' lab results by modifying the document ID in the URL. Discovered by a patient who accidentally typed the wrong URL.
In our pen test experience: IDOR is the finding we see most frequently in SaaS applications — usually in secondary features like export functions, PDF generation, or notification settings, where the ownership check was forgotten during a rushed feature sprint.
Implementing Ownership Checks That Hold
The most reliable pattern is enforcing ownership in the database query itself — not as a separate check after fetching the record. If the query returns nothing because the IDs don't match, the handler returns 404. There's no way to accidentally bypass it.
A reusable ownership pattern in FastAPI / SQLAlchemy:
def get_owned_resource(model, resource_id: int, user): """Fetch a resource and verify the current user owns it.""" resource = db.query(model).filter( model.id == resource_id, model.owner_id == user.id ).first() if not resource: raise HTTPException( status_code=404, detail="Not found" # Don't reveal 403 vs 404 — info leak ) return resource
Return 404, not 403: Returning 403 Forbidden when a user tries to access another user's resource reveals that the resource exists. Always return 404 to prevent ID enumeration attacks.
RBAC vs ABAC — Which to Use
Role-Based Access Control (RBAC) grants permissions based on a user's role (admin, editor, viewer). Attribute-Based Access Control (ABAC) evaluates attributes of the user, resource, and environment to make access decisions.
- RBAC: Simpler to implement and audit. Works well for vertical privilege separation. But RBAC alone doesn't solve IDOR — being a "user" role doesn't tell you which user's data you can access.
- ABAC: More expressive. Can express "a user can access documents in their own organisation" through policy rules. More complex to implement and test.
For most applications: RBAC for vertical access (who can do what) + explicit ownership checks for horizontal access (who can see whose data). This combination covers 90% of access control requirements without the complexity of a full ABAC system.
The Client-Side Access Control Failure
Hiding UI elements from users without enforcing the same restrictions on the server is a common mistake. The "admin dashboard" button is hidden from regular users in the frontend — but the API endpoints it calls aren't protected.
Attackers don't use your frontend. They call your API directly with tools like curl or Burp Suite. If the endpoint isn't protected server-side, the hidden button offers zero security.
Frontend-only access control is not access control. Every permission decision must be enforced server-side, regardless of what the UI shows or hides.
Testing for Broken Access Control
Access control testing requires two accounts at minimum — one "attacker" account attempting to access resources created by the other "victim" account.
- Create a resource as User A, note its ID
- Authenticate as User B
- Attempt to GET, PUT, DELETE the resource using User A's ID
- Repeat for every resource type and HTTP verb
For admin endpoints, test with a regular user account. If you get anything other than 403/404, you have a broken access control finding.
Automated tools like Burp Suite's Autorize plugin can help by automatically repeating requests with a lower-privileged user's session token.
SAST Limitations and Why Code Review Matters
SAST can flag some access control patterns — missing role checks, unauthenticated endpoints — but it struggles with ownership logic. A tool can't easily infer that Order.user_id == user.id is a mandatory check without understanding your application's domain model.
This is why code review remains essential for access control. Specifically, every new endpoint needs a reviewer asking: "What stops User A from accessing User B's data here?" If the answer isn't obvious from the code, it's a problem worth raising.
Centralise your access control logic: If ownership checks are implemented consistently through shared middleware or utility functions, they're easier to audit and harder to forget. Scattered, ad-hoc checks are the pattern where misses happen.
Scan for Access Control Vulnerabilities
AquilaX identifies missing authorization patterns, unauthenticated endpoints, and privilege escalation risks across your API codebase.
Start Free Scan