Broken Access Control
Authentication versus Authorization
It is crucial to distinguish two concepts that are often confused. Authentication answers "who are you?"; authorization answers "what are you allowed to do?". Access control is the implementation of authorization: the rules that determine which resources and actions each user can use. When these rules are absent, incomplete, or applied incorrectly, we have broken access control.
A user can be perfectly authenticated —logged in with valid credentials— and still perform actions that are not theirs to perform if authorization fails. They are independent planes: authentication establishes identity; authorization decides, for each request and each specific resource, whether that identity has the right to proceed. Confusing them leads to the classic mistake of assuming that "if the user is already logged in, they can access whatever they ask for".
The OWASP Top 10 places Broken Access Control in first position (A01:2021), because it is the most widespread and frequently exploited vulnerability category. The reason is that access control depends on each application's specific business logic: there is no generic patch, no library that solves it universally, and no HTTP header that switches it on. Every endpoint, every resource, and every sensitive action needs its own check, and a single unprotected path is enough to open a breach. That dependence on business logic is also why automated tools detect these flaws poorly: the machine does not know which invoice each customer should be allowed to see.
IDOR: Insecure Direct Object References
IDOR (Insecure Direct Object Reference, CWE-639) is one of the most common forms of broken access control. It occurs when an application exposes a direct reference to an internal object (such as a numeric identifier in the URL) and uses that value to access a resource without verifying that the current user has the right to see it.
For example, if when viewing your invoice the URL contains invoice?id=1024 and the application simply returns the invoice with that identifier without checking that it belongs to you, changing the number to 1025 could show you someone else's invoice. The conceptual mechanism is simple: the server trusts a user-controlled parameter to decide which data to deliver, without a server-side authorization check. Since identifiers are often sequential, an attacker can enumerate them (1023, 1024, 1025…) and walk through every user's data systematically. IDORs can affect reading, modifying, or deleting other people's data.
Vulnerable code
This Express endpoint returns the invoice by its id without checking whose it is. Any authenticated user can read everyone else's invoices:
// VULNERABLE: does not verify resource ownership
app.get("/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.query(
"SELECT * FROM invoices WHERE id = $1",
[req.params.id]
);
res.json(invoice.rows[0]);
});
The root cause is in the query: it filters by id, a value the client controls, but never cross-checks that id against the resource's actual owner.
Secure version
The fix ties the resource to the authenticated user in the query itself and responds with 404 when there is no match, without revealing whether the resource exists:
// SECURE: the resource must belong to the authenticated user
app.get("/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.query(
"SELECT * FROM invoices WHERE id = $1 AND owner_id = $2",
[req.params.id, req.user.id]
);
if (invoice.rowCount === 0) {
// 404 (not 403) so we don't confirm someone else's resource exists
return res.status(404).json({ error: "Not found" });
}
res.json(invoice.rows[0]);
});
The AND owner_id = $2 check makes ownership an inseparable condition of the read: even if the attacker guesses the id, the query returns nothing unless they are the owner. The req.user.id comes from the session or the token verified on the server, never from a client-supplied parameter.
Privilege Escalation
Horizontal privilege escalation occurs when a user accesses the resources of another user at the same level: for example, reading the private messages of another customer account. IDOR is often the vehicle for this kind of escalation. The attacker gains no more power, but does gain access to data that is not theirs.
Vertical privilege escalation is more serious: a user with few permissions manages to perform actions reserved for higher roles, like a normal user accessing the admin panel. This usually happens when the application hides administrative functions in the interface but does not protect them on the server, wrongly assuming that "if the button is not visible, no one can call the function." An attacker who knows or guesses the URL of the administrative endpoint can invoke it directly. Remember the principle: security cannot depend on hiding functionality in the client.
Vulnerable code
Here the admin panel is protected only by hiding the link in the interface. The server endpoint does not check the role, so it trusts that "nobody but an admin will know this route":
// VULNERABLE: assumes that if the button is hidden, nobody will call the endpoint
app.delete("/admin/users/:id", requireAuth, async (req, res) => {
await db.query("DELETE FROM users WHERE id = $1", [req.params.id]);
res.json({ deleted: true });
});
Any authenticated user who discovers the route can delete accounts. Client-side hiding is not a security control.
Secure version
Authorization is enforced on the server through a reusable middleware that verifies the role before reaching the handler. If the role is insufficient, the request is rejected with 403:
// Centralized, reusable authorization middleware
function requireRole(role) {
return (req, res, next) => {
if (!req.user || req.user.role !== role) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
// SECURE: the role is verified on the server, on every request
app.delete(
"/admin/users/:id",
requireAuth,
requireRole("admin"),
async (req, res) => {
await db.query("DELETE FROM users WHERE id = $1", [req.params.id]);
res.json({ deleted: true });
}
);
The role is read from req.user, which the server builds from the authenticated session, and is compared against the required role. Because it is a middleware, the same check is reused across every administrative endpoint without duplicating logic or risking forgetting it on one of them.
Other Broken Access Control Patterns
Beyond IDOR and escalation, there are other frequent patterns. Forced browsing consists of directly accessing URLs or files not linked from the interface, discovering panels or resources believed to be hidden. Metadata manipulation occurs when an attacker modifies a token, a cookie, or a field (for example a role=user field) to elevate their permissions.
Also included here are problems of misconfigured CORS that allow unauthorized sites to consume protected APIs, and flaws where an API trusts client parameters to decide the role or tenant. A typical dangerous CORS case is reflecting the received Origin and allowing credentials without validating the origin:
// VULNERABLE: reflects any Origin and enables credentials
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", req.headers.origin);
res.header("Access-Control-Allow-Credentials", "true");
next();
});
// SECURE: only origins from an explicit allowlist are permitted
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.header("Access-Control-Allow-Origin", origin);
res.header("Access-Control-Allow-Credentials", "true");
res.header("Vary", "Origin");
}
next();
});
The common denominator of all these cases is the same: the authorization decision is made in a manipulable place or not made at all. Each sensitive action must explicitly ask "does this user, at this moment, have the right to do this on this specific resource?".
How to Prevent Broken Access Control
The golden rule is to deny by default: no action should be allowed unless there is an explicit check that authorizes it. Centralize the access control logic in a single reusable component —a middleware, a guard, a policy layer— instead of scattering ad-hoc checks throughout the code; this makes it harder to forget one and easier to audit them all. Always verify on the server that the authenticated user owns the resource or has the required role, on every request, without trusting whatever the client sends.
Adopt a clear access control model, such as RBAC (role-based) or ABAC (attribute-based), and apply the principle of least privilege: each role receives only the permissions it needs, nothing more. Never trust role, tenant, or owner fields sent by the client in the body or query: always derive them from the authenticated identity on the server. Avoid using predictable identifiers as the sole access criterion; UUIDs make enumeration harder, but a UUID is not an access control by itself, only an additional layer: the ownership check is still mandatory. Configure a restrictive CORS policy with an origin allowlist, and log denied access attempts to detect abuse.
Detection Tools
To test access control, the most effective technique is to work with several accounts of different privilege (for example, two normal users and one administrator) and attempt cross access. With a proxy like Burp Suite or OWASP ZAP you capture one account's requests and replay them substituting the token or session with another account's, checking whether the server hands over data that isn't theirs. Burp's Autorize extension automates part of this cross-session comparison.
Even so, it is worth being realistic: access control is hard to automate because the tools do not know the business logic nor which resource each user should see. A scanner may flag an endpoint without authentication, but it does not know that invoice 1025 belongs to another customer. That is why manual code review and targeted testing are irreplaceable: you have to read the sensitive handlers and confirm, one by one, that each action verifies ownership and role on the server side.
Prevention Checklist
- Deny by default: every route and action is rejected unless explicitly authorized.
- Verify resource ownership on the server on every request (for example
WHERE id = $1 AND owner_id = $2). - Verify the role with a centralized middleware/guard (
requireRole), never by hiding buttons on the client. - Centralize authorization logic in a reusable component instead of scattered checks.
- Derive
user.id,role, andtenantfrom the verified session or token, never from client parameters. - Apply RBAC/ABAC and least privilege: each role with only the essential permissions.
- Return 404 for other people's resources and do not reveal their existence.
- Do not use predictable identifiers as the sole control; UUIDs are a layer, not an authorization.
- Configure restrictive CORS with an origin allowlist; never reflect
Originwith credentials. - Test with several accounts of different privilege using Burp Suite or OWASP ZAP, and always complement with manual review.
- Log and monitor denied accesses to detect abuse attempts.