Broken access control is the kind of bug that does not crash anything. The app works. Tests pass. Users do their jobs. Then one curious person changes a number in a URL and walks straight into someone else's data. That is broken access control, and it has held the number one spot on the OWASP Top 10 for years for a reason. In this post I want to show you what a broken access control vulnerability actually looks like in real code, why it keeps happening, and the patterns I rely on to prevent it.
What is broken access control in web applications?
Broken access control is any authorization failure that lets a user perform an action or read data outside their intended permissions. That includes viewing another user's invoice, editing a record you do not own, calling an admin endpoint as a regular user, or escalating your role through a hidden API parameter. Authentication asks who you are. Authorization asks what you are allowed to do. Broken access control is always a failure of the second question, never the first.
Why broken access control sits at the top of the OWASP Top 10
The 2021 OWASP Top 10 update moved broken access control from fifth place to first, with 94 percent of tested applications showing some form of access control weakness [SOURCE: OWASP Top 10 2021]. The reason is simple. Most teams write authorization rules by hand, scatter them across controllers, and rely on UI hiding to enforce them. The moment someone bypasses the UI with curl or Postman, the rules collapse.
In my own audits I have found broken access control in roughly two out of every three codebases I review, and it is almost never a sophisticated bug. It is a forgotten check on a copy-pasted endpoint, or a list query that filters by a user id supplied in the request body.
Authentication vs authorization: the difference that matters
If you only remember one thing from this post, remember this. Authentication proves identity. Authorization enforces policy. A valid JWT does not mean the bearer is allowed to do what they are asking. I have seen senior engineers conflate the two and write middleware that checks `requireAuth` then trusts every parameter the request carries. That is the moment broken access control is born.
How broken access control actually happens
Most real-world cases I have seen fall into a small number of patterns. Here are the ones worth memorising:
1. **Insecure Direct Object References (IDOR)**: the URL or body contains an id that the server fetches without checking ownership. 2. **Missing function level checks**: an admin endpoint exists, the UI hides the button, but the route is reachable by anyone with a valid token. 3. **Privilege escalation through input**: the client can send a `role` field on user update, and the server happily writes it. 4. **Force browsing**: predictable URLs like `/invoices/1042` that have no per-request authorization. 5. **CORS or CSRF misconfiguration** that lets a third-party site act on behalf of the user. 6. **Caching authorized responses** in a shared cache, leaking them to the next requester.
### A real IDOR I shipped Early in my career I built a customer portal. Each customer had a dashboard at `/api/customers/:id/orders`. The middleware checked the JWT was valid. It did not check that `:id` matched the JWT's customer id. A friendly support engineer found it in ten seconds by changing the number in the URL. No alarms went off. No logs flagged anything unusual, because from the server's view a logged-in user made a normal API call. That is the scariest part of broken access control: the system does not know it is being abused.
// Vulnerable: trusts the id in the URL
app.get("/api/customers/:id/orders", requireAuth, async (req, res) => {
const orders = await db.orders.findMany({
where: { customerId: req.params.id },
});
return res.json(orders);
});
// Fixed: enforce ownership at the data layer
app.get("/api/customers/:id/orders", requireAuth, async (req, res) => {
if (req.params.id !== req.user.customerId) {
return res.status(403).json({ error: "forbidden" });
}
const orders = await db.orders.findMany({
where: { customerId: req.user.customerId },
});
return res.json(orders);
});
Broken object level and function level authorization in APIs
The OWASP API Security Top 10 splits broken access control into two more specific categories that are worth knowing by name. Broken Object Level Authorization (BOLA) is IDOR for APIs: the server returns or modifies an object without checking that the caller owns it. Broken Function Level Authorization (BFLA) happens when a privileged action, like deleting a user or changing a role, is callable by an account that should not have that power. APIs are especially vulnerable because there is no UI to hide behind. Every endpoint must enforce its own policy on every request.
How to prevent access control vulnerabilities
There is no silver bullet, but there is a small set of habits that cut the risk dramatically. These are the rules I now apply on every project:
- **Deny by default.** Every route requires an explicit allow. No allow, no access. - **Never trust client-supplied identifiers for ownership decisions.** Use the id from the verified session or token. - **Centralise authorization logic.** A single policy module beats authorization checks scattered across controllers. - **Apply least privilege access** to roles, service accounts, database users, and API tokens. - **Treat the API as the source of truth.** UI hiding is a UX nicety, not a security control. - **Write tests for the unhappy path.** For every endpoint, write a test that calls it as the wrong user and asserts a 403.
Role-based vs attribute-based access control
Role-based access control (RBAC) maps users to roles and roles to permissions. It is simple, easy to reason about, and works well when your access rules are coarse. Attribute-based access control (ABAC) makes decisions from attributes of the user, the resource, and the context: time of day, tenant id, document classification, IP range. Most production systems I work with end up as RBAC with a few ABAC rules layered on top, for example role-based UI plus a tenant id check at the data layer for multi-tenant isolation.
// A small policy module beats scattered if/else checks
type Action = "read" | "update" | "delete";
export function canAccessOrder(
user: { id: string; role: "admin" | "member"; tenantId: string },
order: { ownerId: string; tenantId: string },
action: Action,
): boolean {
if (user.tenantId !== order.tenantId) return false;
if (user.role === "admin") return true;
if (action === "read" || action === "update") {
return user.id === order.ownerId;
}
return false;
}
How to test for broken access control
Manual testing catches more access control bugs than scanners ever will, because authorization is business logic and scanners do not know your business. Here is the sequence I run on any new endpoint:
1. Call it without a token. Expect 401. 2. Call it with a valid token but the wrong user. Expect 403. 3. Call it with a valid token and try to mutate fields you should not own, like `role` or `tenantId`. Expect those fields to be ignored or rejected. 4. Try IDs that do not belong to you. Expect 403, not 404, since 404 can leak existence. 5. Replay a previously authorized request after logging out. Expect 401.
Automated tools like Burp Suite, OWASP ZAP, and the open-source Autorize extension help, but they are most useful as a second pass after your own tests.
Audit logs, monitoring, and zero trust
Even with good policy code, you need to know when someone is probing. Log every authorization failure with the user id, route, and resource id. Alert on bursts. In a multi-tenant SaaS, alert on any cross-tenant access attempt, period. The zero trust mindset assumes the network and the client are hostile, which forces every service to enforce its own policy and every request to be authorized on its own merits. That mindset is the single biggest cultural shift that cuts broken access control bugs at the source.
Key takeaways
Broken access control is the number one OWASP Top 10 risk because it hides in plain sight, and the OWASP broken access control category covers everything from IDOR to privilege escalation to misconfigured CORS. Prevent it by denying by default, never trusting client-supplied ids for ownership, centralising your authorization policy, applying least privilege, and writing tests for the unhappy path. Authentication tells you who is knocking. Authorization decides whether to open the door. Get the second one wrong and nothing else you do for security really matters.
Have you dealt with a nasty access control bug in production? I would love to hear the story. Find me on [X / Twitter](https://x.com/ru5iru) or [LinkedIn](https://www.linkedin.com/in/ru5iru) and tell me how you caught it.