Broken Access Control has topped the OWASP Top 10 for years, in the 2021 edition as well as in the 2025 edition. The vulnerability sounds harmless but the impact is enormous: an attacker changes a single number in a URL and suddenly reads other people's bank statements, order details or medical records. No exploit framework, no buffer overflow, just one tampered parameter. That very simplicity is what makes broken access control so dangerous and so widespread.
Insecure Direct Object Reference, or IDOR for short, is its best-known variant. This article places both terms in a clean hierarchy, explains the difference between authentication and authorization, maps the formal CWE taxonomy (CWE-284, CWE-862, CWE-863 and CWE-639) onto the practical OWASP and PortSwigger categories, and shows both vulnerable and secure code. A dedicated section covers JWT and session-related access control flaws, which are missing from most explanations even though they are especially easy to overlook in practice.
The article continues our OWASP series and complements existing foundational guides on SQL injection, directory traversal and the OWASP Top 10. It is written for developers, penetration testers and security engineers who want to understand why access control breaks so often and how to build it correctly.
What is broken access control?
Access control is the mechanism that enforces what users are allowed to do. OWASP puts it concisely: "Access control enforces policy such that users cannot act outside of their intended permissions." When this mechanism fails, we call it broken access control. The typical outcomes: unauthorized disclosure of information, unauthorized modification or destruction of data, or performing a business function outside the user's limits.
One principle matters above all, and it is violated surprisingly often: access control only works when it is enforced in trusted, server-side code where the attacker cannot manipulate the check or its metadata. Anything "validated" in the browser, in front-end JavaScript or in a hidden form field is worthless, because that layer is entirely under the attacker's control.
Why access control breaks so often
Broken access control is rarely the result of a single blunder. It usually grows out of structural and evolutionary problems:
- Unstructured growth: applications that expanded over many versions rarely had a well-designed access control scheme from the start. Permissions were bolted on later, inconsistently.
- Decentralized authorization logic: when the "is this user allowed?" check is scattered across the codebase instead of living in one place, it becomes hard to follow and full of gaps. Every new endpoint is a fresh chance to forget the check.
- System evolution: new features reuse existing data models, authorization logic is added in one spot but not applied consistently everywhere. Refactoring can unintentionally bypass earlier ownership checks.
- Especially critical: administrative remote interfaces that expose powerful functionality and therefore need particularly careful protection.
Authentication is not authorization
The single most common mistake behind almost every access control flaw is: "the user is logged in, so they are allowed." That is wrong. Two separate questions must be answered:
- Authentication (AuthN): "Who are you?" The server verifies identity, for example via password, session cookie, token or multi-factor verification.
- Authorization (AuthZ): "Are you allowed to do exactly this?" The server verifies whether that authenticated identity is permitted to view this specific resource or perform this specific action.
IDOR and broken access control are, at their core, always AuthZ failures: the server checks whether someone is logged in but fails to check whether that someone is authorized for the resource actually requested. A logged-in user is only authorized for their own data, not automatically for everyone else's.
The hierarchy: BAC, IDOR and the CWE taxonomy
In practice "broken access control", "IDOR", "BOLA" and various CWE numbers get thrown around interchangeably. A clean hierarchy helps:
- Broken Access Control (OWASP A01) is the umbrella term for all kinds of faulty access control.
- CWE-284 (Improper Access Control) is the formal, abstract "pillar" category at the very top of the CWE hierarchy. MITRE explicitly discourages using CWE-284 for concrete mapping because it is too general; you should use more specific descendants instead.
- CWE-862 (Missing Authorization) describes the case where no authorization check is performed at all.
- CWE-863 (Incorrect Authorization) describes the case where a check exists but is flawed and can be bypassed.
- CWE-639 (Authorization Bypass Through User-Controlled Key) is the formal name for what is usually called "IDOR": a user accesses someone else's data by altering a controllable key value, without the system verifying that they are allowed to access that specific record.
The distinction between CWE-862 and CWE-863 matters more than it looks. "No check present" and "flawed check present" require different fixes: in the first case a check must be added at all, in the second the existing logic must be corrected. In OWASP API Security terminology, IDOR appears as Broken Object Level Authorization (BOLA), which is the same thing, just named at the API object level.
Horizontal, vertical and context-dependent access control
The PortSwigger Web Security Academy distinguishes three fundamental types of access control. This classification is the best framework for reasoning about attacks.
Vertical access control
It restricts sensitive functions to certain user types. An admin can delete accounts, a regular user cannot. When vertical control breaks, we speak of vertical privilege escalation: a normal user gains admin functions. Common causes are unprotected admin pages, paths exposed in robots.txt or front-end JavaScript, or parameter-based roles stored client-side:
https://insecure-website.com/login/home.jsp?admin=true
https://insecure-website.com/login/home.jsp?role=1
Change admin=false to admin=true and you gain unauthorized access, because the role is not verified server-side.
Horizontal access control
It restricts resource access to certain users. A banking app shows only your own transactions. When horizontal control breaks, we speak of horizontal privilege escalation: a user accesses the resources of a same-level peer. This is the classic IDOR:
https://insecure-website.com/myaccount?id=123
The attacker changes id=123 to id=124 and sees someone else's account. IDOR is by definition mostly a horizontal attack, but it can escalate vertically: if you take over a privileged user's account through a horizontal flaw, it becomes full admin access (horizontal-to-vertical escalation).
Context-dependent access control
It prevents actions from being performed in the wrong order, such as modifying the contents of a shopping cart after payment has completed. When access controls are applied inconsistently across multi-step processes, attackers can skip controlled intermediate steps and send the final request directly.
IDOR in detail: the three necessary elements
The official OWASP Cheat Sheet defines IDOR very precisely through three necessary components. Only their combination produces the vulnerability:
- An object that exists (an account, a document, an order).
- A reference to it that the user can control (an ID, UUID or slug in a URL, form field, header or cookie).
- A missing object-level authorization check.
If the third element is absent, you have IDOR. Aikido, one of the leading sources on the topic, sums it up: "The vulnerability is not the identifier itself. The vulnerability is the assumption that the identifier belongs to the authenticated user, without revalidating that assumption on the server."
Where vulnerable identifiers live
Vulnerable references show up almost anywhere applications use user input to select objects:
- URL path or query parameters:
/user/id/1234,?customer_number=132355,?id=16. - Request body fields: JSON or form fields such as
user_idin a POST/PUT request. - Hidden form fields:
<input type="hidden" name="user_id" value="12345">, trivially changed in the browser. - HTTP headers: custom headers like
X-User-ID. - Cookies: ID values in cookies that determine the user context.
- File names:
/static/pdfs/1.pdf,/documents/annual-report.pdf, often sequential and therefore guessable. - API response IDs that are reused in subsequent requests.
The IDOR variants
Across the sources, several forms can be distinguished:
- Horizontal IDOR: access to objects of a same-level user.
- Vertical IDOR: access to privileged or administrative objects.
- Blind IDOR: the server response shows no data, but the action still affects a foreign object (for example an unauthorized change with no visible result). These are especially easy to miss because no obvious data leak appears.
- Numeric vs UUID: sequential integers are trivial to enumerate; UUIDs make guessing harder but do not remove the flaw (see below).
- Chained IDOR: several smaller flaws are combined into an account takeover ("bug chaining"). Example: a multi-step approval workflow checks ownership correctly when a request is created but fails to recheck it when the pending item is later resumed. Another authenticated user substitutes the request ID and reaches foreign items, even though they are properly logged in themselves.
- Mass assignment: a related pattern where attackers inject unintended fields into a request, such as
isAdmin=trueorcredit=10000.
Vulnerable vs secure code
The following pattern is at the heart of nearly every IDOR: an object is loaded directly by the user-supplied ID, with no ownership check. First, a vulnerable FastAPI endpoint in Python:
@app.get("/users/{user_id}")
async def get_user_by_id(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
There is no authorization check at all. Anyone who changes user_id in the URL gets someone else's data. The secure version compares the authenticated identity with the requested ID and returns HTTP 403 on a mismatch:
@app.get("/users/{user_id}")
async def get_secure_user(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if current_user.id != user_id:
raise HTTPException(status_code=403, detail="Not authorized to access this user")
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
The same problem in Node.js/Express, vulnerable: the server only checks whether the user is logged in, not whether they own the resource.
app.get("/user/id/:id", (req, res) => {
const user = db.users.find(req.params.id);
if (req.isAuthenticated()) {
// Authentication is not enough!
res.render("user", { user });
}
});
It only becomes secure once ownership is verified as well:
app.get("/user/id/:id", (req, res) => {
const user = db.users.find(req.params.id);
// The user must be authenticated AND authorized
// Normalize types: session IDs and route params can differ (Number vs String)
if (req.isAuthenticated() && String(req.session.userId) === String(req.params.id)) {
res.render("user", { user });
} else {
return res.status(403).json({ message: "Forbidden" });
}
});
The cleaner approach: enforce ownership through the data model
The most robust solution is not to write the ownership check as a separate if condition at all, but to build it straight into the data query. The OWASP Cheat Sheet shows this with a Ruby/ActiveRecord example:
# vulnerable: searches ALL projects
@project = Project.find(params[:id])
# secure: searches only the current user's projects
@project = @current_user.projects.find(params[:id])
The difference is subtle but decisive: instead of searching globally across all records and checking afterwards, the query is scoped from the outset to the records that belong to the authenticated user. If the object does not exist for that user, the query simply returns nothing. If a developer later forgets this scope, it is far more likely to be noticed than a missing separate check.
MITRE's formal CWE examples show the same anti-pattern. This C# example (CWE-639) correctly protects against SQL injection through a parameterized query but contains no authorization check whatsoever:
conn = new SqlConnection(_ConnectionString);
conn.Open();
int16 id = System.Convert.ToInt16(invoiceID.Text);
SqlCommand query = new SqlCommand("SELECT * FROM invoices WHERE id = @id", conn);
query.Parameters.AddWithValue("@id", id);
SqlDataReader objReader = objCommand.ExecuteReader();
Even though the UI only shows the user's own invoices, an attacker who bypasses the UI and sends any id directly can retrieve every invoice. This is the key lesson: protection against SQL injection and protection against IDOR are two entirely different things. A parameterized query alone does not make an endpoint safe.
Why UUIDs alone are not enough
A widespread misconception says: "We use UUIDs instead of sequential IDs, so we are protected against IDOR." That is false, and several primary sources (the OWASP Cheat Sheet, Hadrian, Aikido) stress it independently.
UUIDs and complex identifiers make guessing valid values drastically harder, but they do not replace an authorization check. The OWASP Cheat Sheet is unambiguous: even with complex IDs, "access control checks are essential". The reason: attackers often do not need to guess IDs at all. They obtain valid identifiers through perfectly ordinary channels, from endpoint responses, shared links, logs, browser history or intercepted requests. As soon as an attacker knows a valid foreign UUID and no ownership check exists, the IDOR works just as it would with a sequential number.
UUIDs are therefore a sensible defense-in-depth measure that prevents automated enumeration. But they are never the actual control. The actual control is always the server-side check "does this object belong to the requesting user?".
Force browsing and missing function-level access control
Not every access control flaw is an IDOR. A second major category is missing function-level access control, often exploited through force browsing (formally CWE-425, Direct Request/Forced Browsing). Here an attacker simply requests a URL directly that is not even linked in the UI:
/app/getappInfo (should require authentication)
/app/admin_getappInfo (should require admin rights)
If the server-side check is missing, an unauthenticated user reaches protected pages or a normal user reaches admin functions. Related techniques for bypassing URL restrictions are particularly nasty because they often exploit poorly configured routing:
- Non-standard headers: an
X-Original-URLorX-Rewrite-URLheader overrides the checked path in some frameworks.
POST / HTTP/1.1
X-Original-URL: /admin/deleteUser
- URL matching discrepancies: different casing (
/ADMIN/DELETEUSER), extra file extensions (/admin/deleteUser.anythingin certain Spring configurations) or a trailing slash (/admin/deleteUser/). - Referer-based control: if an application relies on the
Refererheader for authorization, the attacker can set that header to anything.
A frequently overlooked special case is privilege de-escalation: access controls are often only guarded against escalation upward. That an attacker can accidentally or deliberately downgrade a high-privilege user, sabotaging admin accounts, is rarely tested.
JWT and session-related access control flaws
JSON Web Tokens (JWT) are today's standard for stateless authentication in APIs, and this is exactly where access control flaws happen that most IDOR explanations skip. The OWASP A01 category list names them explicitly: "metadata manipulation, such as replaying or tampering with a JWT access control token." Two error classes are especially common.
Flaw 1: the token is decoded but not verified. A JWT consists of a header, a payload and a signature. The signature is the only protection against tampering. Anyone who merely decodes the token (reads the payload) without verifying the signature trusts values the attacker can set freely, such as a role claim.
Flaw 2: the identity from the token is not checked against object ownership. Even a correctly signed token only says who the user is, not what they may access. The following Node.js example (using the jsonwebtoken library) makes both mistakes at once:
const jwt = require("jsonwebtoken");
// VULNERABLE: trusts the role claim and checks no ownership
app.get("/api/orders/:orderId", (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
const payload = jwt.decode(token); // decode() does NOT verify the signature
if (payload.role === "user") {
const order = db.orders.findById(req.params.orderId);
return res.json(order); // returns ANY order, no matter who owns it
}
});
Two attacks become possible. First, an attacker can defeat signature verification, because jwt.decode() simply skips it, and grant themselves role: "admin" via a tampered payload. Second, and this is the actual IDOR, the endpoint returns any orderId because there is no match between the token holder and the order owner. The secure version fixes both:
const jwt = require("jsonwebtoken");
app.get("/api/orders/:orderId", (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
let payload;
try {
// verify() checks the signature and rejects "alg: none" or tampered tokens
payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"] });
} catch (err) {
return res.status(401).json({ message: "Invalid token" });
}
// Enforce ownership: only load orders belonging to the token holder
const order = db.orders.findOne({ id: req.params.orderId, ownerId: payload.sub });
if (!order) {
return res.status(403).json({ message: "Forbidden" });
}
return res.json(order);
});
Three things make the difference. jwt.verify() checks the signature and, with the explicit algorithms option, also rejects the notorious "alg: none" attack and algorithm confusion. The identity is derived from the verified token (payload.sub), never from the request. And the data query binds the object directly to the token holder (ownerId: payload.sub), so foreign orders are never even loaded.
Beyond that, handling tokens and sessions securely means: stateful session identifiers must be invalidated server-side on logout. For stateless JWTs, prefer short lifetimes, because an issued token otherwise remains valid until it expires, even after a logout. Sequential or guessable session IDs are their own form of CWE-639, because they let attackers switch between other users' sessions.
Prevention: building access control correctly
Across all primary sources a consistent pattern emerges. The following principles work together.
Deny by default
Unless a resource is explicitly meant to be public, access is denied by default. PortSwigger states it as a ground rule: "Unless a resource is intended to be publicly accessible, deny access by default." New endpoints are then protected out of the box, not open.
Server-side, on every access
The check belongs in trusted server code and must run on every access, not just the first. Multi-step workflows in particular break when ownership is checked only at creation and no longer on later edits. Front-end checks are pure convenience for the user, never security. Front-end-only protection fails instantly against direct API requests, for example via curl.
Enforce ownership through the domain model
Instead of generic CRUD rights ("may read"), the question "does this object belong to the user?" belongs in the domain model, ideally as a scoped query like the Ruby example above. OWASP additionally recommends not exposing identifiers in the first place: derive the authenticated user from session or token data rather than from URL or body, and pass IDs through the session in multi-step flows.
RBAC and ABAC: choosing the right model
Two models have become established for structured authorization, both of which we cover in more depth in our guide to identity and access management:
- RBAC (Role-Based Access Control): permissions attach to roles, and users are assigned roles (such as "user", "editor", "admin"). RBAC is easy to understand and audit and fits well when permissions map cleanly onto a few clearly separated roles. With very fine-grained or object-dependent rules, RBAC quickly becomes unwieldy (role explosion).
- ABAC (Attribute-Based Access Control): decisions are based on attributes of the user, the resource and the context (such as department, owner, time of day, tenant). ABAC is more expressive and suits dynamic, context-dependent rules and multi-tenant environments, but it is more complex to implement and test.
A rule of thumb: RBAC when a few clear roles suffice; ABAC (or a combination) once object-level ownership, tenant boundaries or context-dependent conditions come into play. For complex scenarios, centralized policy-as-code is a good fit, for example with the Open Policy Agent (OPA), so that authorization logic lives in one place rather than scattered across the codebase.
Further proven measures
- Unguessable IDs (UUIDs) as defense in depth against enumeration, but never as a substitute for the check.
- Centralized rather than scattered authorization logic: implement the mechanism once, reuse it everywhere.
- Validate tenant boundaries on every request in multi-tenant systems.
- Rate limiting for API and controller access to curb automated enumeration.
- Log access control failures and alert administrators on repeated failed attempts.
- Disable directory listing and keep metadata such as
.gitout of the web root. - Functional access control tests as unit and integration tests in the pipeline.
Why automated scanners fail and how to test properly
One reason broken access control is so persistent: automated scanners find it poorly by design. With XSS or SQL injection there are clear signatures (reflected payloads, error messages). Access control has no such signature. A scanner "does not know whether a given invoice belongs to user A or user B". A technically clean response (HTTP 200) is treated as harmless even though foreign data came back. Static analysis additionally suffers from high noise, because authorization logic often lives in middleware or shared services.
Access control therefore needs human judgment and context-aware testing. Two practical methods:
The two-browser (or two-user) technique
Create two test accounts (user A and user B), each with their own objects. Keep one browser tab logged in as user A and use a second (for example in incognito mode) as the attacker. Then systematically try to access user B's objects as user A, and do so for every operation: read, create, update, delete, export, administration. Edge cases matter, such as id=0 and negative numbers, which developers frequently overlook. Test all attack surfaces: URL parameters, POST body, HTTP headers, cookies and API response IDs that are reused in follow-up requests.
Burp Suite and the Autorize extension
Manually, the path leads through Burp Suite: intercept the target request, filter for parameters that look like resource identifiers, send the request to the Repeater and change the parameter systematically. For IDOR testing at scale, the Burp extension Autorize is the de facto standard: you configure the session cookies of a low-privilege user, then browse the application as a high-privilege user, and Autorize automatically replays every request with the low-privilege user's cookies. Results are color-coded (green: correctly blocked, red: bypassed, orange: check manually). At a glance you see which endpoints fail to enforce authorization. Complementary tools are OWASP ZAP, Nuclei IDOR templates, ffuf and wfuzz for fuzzing, and Postman for manual API testing.
Systematic testing is closely related to the work done in bug bounty programs, where IDOR is among the most frequently reported and often well-rewarded vulnerability classes.
Real-world incidents and CVEs
Broken access control is not theoretical. A few documented cases, in chronological order, each tied to publicly tracked CVEs where applicable:
- US Department of Defense (2020): the security firm Silent Breach discovered an IDOR vulnerability that allowed unauthorized access. According to Wikipedia it was fixed by introducing a user session mechanism.
- Parler (2021): sequential post identifiers reportedly enabled massive data scraping. Important: Wikipedia itself notes that the researcher involved disputed parts of the media reporting on the technical details. The case should therefore be treated with caution and is not an undisputed fact.
- Instagram (2022): an IDOR in the "Interests" feature let attackers change other users' interests by tampering with a user ID in POST data. A bug bounty reward of 4,300 US dollars is reported (secondary source, not independently verified).
- CVE-2023-4836 (WordPress plugin "User Private Files"): allowed users with minimal privileges to retrieve files from other users' folders (source: MDN, no CVSS score stated).
- TikTok (2023): manipulating the
aweme_idparameter reportedly exposed other users' private "Memories" (secondary source). - CVE-2021-36539: listed by MITRE as a classic CWE-639 example, an education application that failed to properly restrict file IDs to specific users, letting attackers brute-force them.
Other cases named in secondary sources but not independently verified here include CVE-2024-46528 (KubeSphere), CVE-2024-55471 (Oqtane), CVE-2024-48899 (Moodle course badges) and CVE-2025-13526 (WordPress plugin "OneClick Chat to Order", reported as CVSS 7.5, where a sequential order_id allowed unauthorized mass retrieval of customer data). These numbers are useful research starting points but should be reconciled with the official NVD before citing specific technical claims.
Broken access control in the OWASP Top 10: the numbers
Broken access control leads the OWASP Top 10, in the 2021 edition as well as in the 2025 edition. To keep the figures straight, here are both editions cleanly separated, each taken directly from the official OWASP primary source:
- OWASP Top 10:2021: 34 mapped CWEs, average incidence rate 3.81 percent, maximum incidence rate 55.97 percent, 318,487 occurrences and 19,013 mapped CVEs.
- OWASP Top 10:2025: 40 mapped CWEs, average incidence rate 3.74 percent, maximum incidence rate 20.15 percent, 1,839,701 occurrences and 32,654 mapped CVEs.
One frequently quoted statement deserves context: "100 percent of the applications tested were found to have some form of broken access control." This sentence comes verbatim from the OWASP 2025 page, but it measures something different from the incidence rate. The 100 percent statement means: practically every application tested had at least one occurrence of any of the mapped CWEs. The average incidence rate of 3.74 percent, by contrast, refers to the share of test cases that exhibited a single, specific CWE pattern, averaged across all 40 weakness classes. Both figures are correct; they simply describe different things and must not be conflated.
All three central CWEs (CWE-639, CWE-862 and the abstract pillar CWE-284) are listed both under OWASP Top 10 category A01 and in the CWE Top 25 most dangerous software weaknesses. CWE-639 additionally carries a "high likelihood of exploitation". For a full breakdown of all categories, see our overview of the OWASP Top 10.
FAQ
What is Broken Access Control?
Broken access control is a security flaw where an application fails to properly enforce what authenticated users are allowed to do. As a result, users can act outside their intended permissions, for example reading, modifying or deleting data that belongs to someone else. It sits at number one in the OWASP Top 10 (A01) in both the 2021 and 2025 editions.
What is the difference between IDOR and Broken Access Control?
Broken access control is the umbrella term for all kinds of faulty access control. IDOR (Insecure Direct Object Reference) is one specific variant of it: a user changes a controllable reference, such as an ID in a URL, and reaches another user's object because the server never checks ownership. Formally, IDOR maps to CWE-639, while broken access control corresponds to the broader OWASP A01 category.
What is the difference between horizontal and vertical privilege escalation?
Horizontal privilege escalation means a user accesses the resources of a same-level peer, for example viewing another customer's account. Vertical privilege escalation means a lower-privileged user gains higher-privileged functions, for example a normal user reaching admin features. Classic IDOR is usually horizontal, but a horizontal flaw against a privileged account can escalate into full vertical (admin) access.
How do you prevent IDOR vulnerabilities?
Enforce an object-level authorization check on the server for every access, verifying that the requested object actually belongs to the authenticated user. The most robust approach scopes the data query to the current user (for example current_user.projects.find(id)) instead of checking after the fact. Complement this with deny-by-default policies, centralized authorization logic and functional access control tests.
Are UUIDs enough to stop IDOR?
No. UUIDs make guessing valid identifiers much harder, but they do not replace an authorization check. Attackers frequently obtain valid IDs through ordinary channels such as API responses, shared links, logs or intercepted requests, and the IDOR then works exactly as it would with a sequential number. UUIDs are a useful defense-in-depth layer, never the actual control.
Conclusion
Broken access control is not number one in the OWASP Top 10 because it is especially sophisticated, but because it is so easy to introduce and so hard to find automatically. The common thread through every variant, whether horizontal IDOR, vertical privilege escalation, force browsing or JWT flaws, is always the same: the application confuses authentication with authorization and fails to verify server-side, on every access, that the user is authorized for that specific object.
Building it correctly follows a few principles: deny by default, server-side checks on every access, enforce ownership through the domain model, implement a suitable model (RBAC or ABAC) in a centralized way, and treat UUIDs as a complementary layer rather than a load-bearing one. Testing is done not with scanners alone but with human judgment, the two-user technique and tools like Burp Suite and Autorize. That turns one of the most common vulnerabilities in web development into a manageable one.