July 7, 2026
OWASP Top 10 2025 A01: Where Broken Access Control Fails in Real Applications
Authentication works. The UI looks correct. The happy path passes. But access control still fails when the application checks the wrong…

By Przemyslaw
8 min read
Authentication works. The UI looks correct. The happy path passes. But access control still fails when the application checks the wrong thing, in the wrong place, or trusts data it should not trust.
In the first article of this series, I started before the exploit.
I looked at response consistency, error messages, response length, timing, privileged account naming, password policy, and small signals that can tell us whether an application is worth investigating further.
This time I want to move closer to the actual access control failure.
But I do not want to reduce A01 Broken Access Control to only one pattern: changing an ID in a URL and accessing someone else's data.
That is an important case, but A01 is wider than IDOR.
Broken Access Control can happen when an application checks authentication instead of authorization. It can happen when the backend trusts request parameters. It can happen when one action on a resource is protected, but another action is not. It can happen when a dashboard exposes internal references and the referenced functionality is still reachable. It can also happen when a workflow checks access too early, but not where the sensitive action actually happens.
The core idea is simple:
Broken Access Control happens when the application makes an authorization decision too early, too broadly, or based on data it should not trust.
That is the mindset I want to use in this article.
Mistake 1: treating authentication as authorization
Authentication and authorization are connected, but they are not the same control.
- Authentication asks: who are you?
- Authorization asks: are you allowed to do this?
A valid session, cookie, or token proves that the application recognised a user. It does not prove that this user can access a specific object, perform a specific action, or operate inside a specific tenant, company, team, or organisation.
A common mistake is protecting an endpoint with only a login check:
isAuthenticated ✅
isAllowed(user, action, resource) ❌isAuthenticated ✅
isAllowed(user, action, resource) ❌The endpoint may be unavailable to anonymous users, but still available to any logged-in user.
That can lead to horizontal access issues, vertical access issues, or business function abuse. A normal user is not anonymous, but they are still not automatically an administrator, owner, manager, approver, or member of every organisation.
Being logged in is only the start of the decision. It is not the final decision.
How I would review it
For every sensitive request, I would ask:
- Who is the user?
- What resource are they trying to access?
- What action are they trying to perform?
- Are they allowed to do that?
The check should not stop at "valid session". The application needs an authorization decision at the point where the resource is accessed or the action is performed.
Mistake 2: trusting request parameters
Another common mistake is allowing request data to define the access context.
For example:
{
"userId": "user_123",
"companyId": "company_456",
"role": "admin",
"ownerId": "user_123",
"approved": true
}{
"userId": "user_123",
"companyId": "company_456",
"role": "admin",
"ownerId": "user_123",
"approved": true
}Some of these fields may be legitimate business data in the right context. The problem starts when the backend treats them as trusted proof of permission.
If the user can control fields like userId, companyId, role, ownerId, isAdmin, approved, or similar values, they can try to move themselves into a different access context.
The request is not a trusted source of identity or permission. The request is input. And input can be modified.
This is especially risky when backend logic uses request values directly in authorization decisions:
if request.companyId == target.companyId:
allowif request.companyId == target.companyId:
allowThat looks like a check, but it may still be based on attacker-controlled data.
How I would protect it
The authenticated user should come from the server-side session or validated token context, not from the request body.
Do not ask the request who the user is. Derive the user from the authenticated context.
Then use server-side data to decide access. For example, instead of trusting companyId from the body, load the user's memberships from the database and check whether this user is allowed to act inside that company.
Request parameters can identify what the user wants to access. They should not prove that the user is allowed to access it.
Mistake 3: protecting the resource, but not the action
This one is easy to describe badly, so I want to be precise.
In backend frameworks, we define routes, handlers, controllers, and HTTP methods. The problem is not that GET, POST, PATCH, or DELETE magically exist without code.
The problem is that each handler may represent a different action, and each action needs its own authorization decision.
For example:
GET /api/reports/rpt_123 -> can view?
PATCH /api/reports/rpt_123 -> can edit?
DELETE /api/reports/rpt_123 -> can delete?
POST /api/reports/rpt_123/export -> can export?GET /api/reports/rpt_123 -> can view?
PATCH /api/reports/rpt_123 -> can edit?
DELETE /api/reports/rpt_123 -> can delete?
POST /api/reports/rpt_123/export -> can export?This may be the same resource, but it is not the same permission.
A user may be allowed to view a report, but not edit it. A user may be allowed to edit a record, but not delete it. A user may be allowed to see data in the application, but not export it.
If the application checks only "can access this resource", the permission may become too broad.
Access control should not only ask whether the user can reach an object. It should also ask what the user is trying to do with it.
How I would protect it
Use action-specific authorization:
can(user, "view", report)
can(user, "edit", report)
can(user, "delete", report)
can(user, "export", report)can(user, "view", report)
can(user, "edit", report)
can(user, "delete", report)
can(user, "export", report)Defining a route is not the same as protecting the action behind that route. Each handler needs an authorization decision based on the user, resource, scope, and action.
This is where access control becomes more than a single isAdmin check.
Mistake 4: exposing internal references without enforcing permission
This is more interesting than simply saying "do not hide buttons in the frontend".
Imagine a user can access a dashboard. The dashboard contains references to pages or actions that should only be available to a more privileged user.
For example, the dashboard response or UI reveals:
/admin/users
/admin/users/123
/admin/users/123/actions
POST /admin/users/123/delete
POST /admin/users/123/change-role/admin/users
/admin/users/123
/admin/users/123/actions
POST /admin/users/123/delete
POST /admin/users/123/change-roleThe link or reference itself is not always the full vulnerability. But it gives structure. It tells the tester where to look next.
If the referenced page or action is still reachable without the required permission, the application has a real access control problem.
The issue is not only that the application revealed a link. The issue is that the revealed page or action is still accessible.
This connects with Part 1. Small signals and references can become a map. In Part 2, we follow that map and check whether authorization is actually enforced.
How I would protect it
The backend must enforce permission on every referenced page and action.
A dashboard can guide legitimate users, but it should not become the access control layer.
If /admin/users requires an admin permission, then /admin/users, /admin/users/123, and every sensitive action behind those pages must enforce that permission too.
Visible reference ≠ permission
Known URL ≠ permission
Reachable route ≠ permissionVisible reference ≠ permission
Known URL ≠ permission
Reachable route ≠ permissionAccess control should not depend on whether the user discovered the route through the intended UI.
Mistake 5: checking access too early in the workflow
Some applications check access at the beginning of a workflow, but not at the final state-changing request.
For example:
GET /settings/users -> checked
GET /settings/users/123/edit -> checked
POST /settings/users/123/role -> weak or missing checkGET /settings/users -> checked
GET /settings/users/123/edit -> checked
POST /settings/users/123/role -> weak or missing checkThe application may assume that because the user reached the final step through the normal UI, they must be allowed to perform the final action.
That is a dangerous assumption.
Attackers do not have to follow the intended workflow. They can replay, modify, or directly send the final request.
If the final request changes state, assigns a role, deletes data, exports data, approves something, or changes ownership, that request must make its own authorization decision.
Sensitive state change = authorization check at the moment of change.
Checking access on the page before the action is useful, but not sufficient. The action itself needs the check.
Mistake 6: mixing role, ownership, scope, and state
A role is not the same as ownership. Ownership is not the same as tenant scope. Tenant scope is not the same as action permission. Workflow state can add another condition.
This is where access control gets messy in real applications.
For example:
role: manager
scope: team A
resource: report from team B
action: approverole: manager
scope: team A
resource: report from team B
action: approveA user may be a manager, but only for their own team. A user may own a record, but not be allowed to approve it. An administrator may manage users, but not necessarily have a business reason to read every sensitive record.
If the application checks only one dimension, it may allow too much:
user.role === "manager" ✅
resource belongs to this manager’s scope ❌
action is allowed for this state ❌user.role === "manager" ✅
resource belongs to this manager’s scope ❌
action is allowed for this state ❌That is how a role check becomes too broad.
How I would model it
A better authorization decision uses more than one input:
- Role: what type of user are you?
- Ownership: does this object belong to you?
- Scope: are you allowed inside this tenant, company, or team?
- Action: what are you trying to do?
- State: is this action allowed at this point in the workflow?
This does not always mean building a huge permission engine from day one. But it does mean avoiding a single isAdmin or isManager flag as the whole access model.
Mistake 7: testing only the happy path
Many access control bugs survive because tests only prove that valid users can do valid things.
For example:
Owner can edit report ✅
Admin can delete user ✅
Manager can approve request ✅Owner can edit report ✅
Admin can delete user ✅
Manager can approve request ✅That is useful, but incomplete. Security testing also needs negative cases:
Other user cannot edit report
User from another company cannot view report
Normal user cannot call admin action
Manager from team A cannot approve team B requestOther user cannot edit report
User from another company cannot view report
Normal user cannot call admin action
Manager from team A cannot approve team B requestThe application can look correct because the normal flow works, while the boundary is still broken.
If tests do not verify who must be denied, the access model is only half tested.
How I would test it
A simple pattern is:
User A creates or owns an object.
User B tries to read, edit, delete, export, or approve it.
Expected result: denied.
Expected result: no data leak.
Expected result: no state change.User A creates or owns an object.
User B tries to read, edit, delete, export, or approve it.
Expected result: denied.
Expected result: no data leak.
Expected result: no state change.Then repeat the same idea across roles, tenants, workflow states, and sensitive actions.
Practical review questions
When reviewing an endpoint or action, I would ask:
- Is this endpoint only checking authentication?
- Is the user identity taken from a trusted context or from a request input?
- Does the request contain fields like userId, role, companyId, ownerId, or isAdmin?
- Is the application checking the specific action, not only the resource?
- Are view, edit, delete, export, approve, and assign treated as different permissions?
- Does a dashboard, API response, or client-side file reveal sensitive routes?
- If a sensitive route is known, does the backend still enforce permission?
- Is the final state-changing request protected?
- Would the same request fail for a different user, role, tenant, or scope?
That last question is important. Access control is not proven only by testing the user who is allowed. It is also proven by testing users who should be denied.
Summary
Broken Access Control is not only about changing an ID in a URL.
It also happens when the application treats login as permission, trusts request parameters, protects the resource but not the action, exposes internal references without enforcing permission, checks access too early, mixes role with ownership and scope, or tests only the happy path.
The main lesson for me is that access control must be checked where the sensitive thing actually happens: where data is read, where data is changed, where an export is created, where a role is assigned, where ownership changes, and where a business action is performed.
A valid session, visible link, known URL, previous workflow step, or request parameter is not proof of access.
The authorization decision belongs at the point of action.
My takeaway
The more I learn AppSec, the more I see A01 as a design and implementation problem, not only a "find the vulnerable request" problem.
The vulnerable request matters. But the real question is why the request was trusted.
Was the check too early? Was it too broad? Was it based on user-controlled data? Was the action protected separately? Was the route reachable only because nobody enforced permission behind it? Was the wrong user ever tested?
That is where Broken Access Control actually happens.
In the next article, I want to focus fully on remediation: how to enforce access control in a way that is predictable, testable, and harder to bypass.