Every OWASP Top 10 revision reorders the list based on real-world prevalence and impact data, and Broken Access Control has held the #1 spot for good reason: it’s not one bug, it’s a whole category of “the server did what was asked without checking whether it should have.” That breadth is exactly what makes it the top risk - there’s no single patch for it, no library upgrade that closes it, and no scanner that reliably finds every instance of it. It has to be designed against, route by route, for the life of the application.

What “access control” actually covers

Access control is the set of checks that answer two related but distinct questions on every request:

  • Authentication: who is making this request?
  • Authorization: is that specific identity allowed to do this specific thing, to this specific object?

Broken Access Control is what happens when an app nails the first question and skips, weakens, or miswires the second. The request isn’t forged, the session isn’t stolen - the user is exactly who they say they are, doing something the app simply never told them “no” for.

The shapes it takes

Insecure Direct Object References (IDOR). Reading or modifying another user’s data by supplying their object’s identifier instead of your own, because the server fetched the object without confirming it belongs to the requester. This is common and specific enough to warrant its own write-up - see the dedicated IDOR deep dive for a full walkthrough. Every other pattern below is the same root cause wearing a different outfit.

Missing function-level access control. An endpoint or UI action exists that only admins should reach, but the server-side check is missing or only enforced by hiding the button in the frontend:

// The UI only shows this button to admins - but the route itself
// doesn't check who's asking, so hiding the button isn't a real barrier.
app.post('/api/users/:id/promote', requireAuth, async (req, res) => {
  await User.update(req.params.id, { role: 'admin' });
  res.json({ ok: true });
});

Anyone who knows (or guesses) the endpoint exists can call it directly with a valid session of their own, bypassing a UI restriction that was never backed by a server-side one.

Privilege escalation via parameter tampering. A request body or hidden form field carries a role, price, or permission level the client shouldn’t be trusted to set:

{ "email": "me@example.com", "password": "...", "role": "admin" }

If the signup handler blindly assigns whatever role arrives in the body instead of hardcoding it server-side, self-service admin accounts are one JSON edit away.

Mass assignment. Related to the above - binding an entire request body straight onto a database model (User.update(req.body)) updates every field the client sent, including ones the UI never exposed, unless the handler explicitly allowlists which fields are writable.

CORS misconfiguration. Reflecting any Origin header back with Access-Control-Allow-Credentials: true effectively lets any website read authenticated responses from a victim’s browser on the user’s behalf - broken access control at the browser-trust layer rather than the API layer.

Forced browsing. Reaching an unlinked-but-unprotected URL directly (/admin/reports, /internal/debug) that was never given a server-side check because “no one links to it” was mistaken for “no one can reach it.”

How it’s tested for

Unlike an injection bug, access control failures don’t announce themselves with an error message - the response usually looks completely normal, just wrong for who asked. That makes the testing methodology matter more than any tool:

  1. Map every role the app has (anonymous, free user, paid user, admin, support staff) and every action/route available to each.
  2. For every action, ask two separate questions: can a lower-privileged role reach a higher-privileged action (vertical escalation), and can a user reach another user’s data at the same privilege level (horizontal escalation, i.e. IDOR)? Most test plans cover one and forget the other.
  3. Test server-side, not just UI-side. If a button is hidden for your test role, that proves nothing - replay the underlying request directly with that role’s credentials and confirm the server itself refuses it.
  4. Don’t stop at read access. Write, update, delete, and admin-only actions are usually the more damaging half of this category and are tested the same way, just with a request body instead of a query string.
  5. Re-test after every refactor. Access control is uniquely prone to regressing silently: a route added during a refactor, a new admin action bolted onto an existing controller, a middleware order change that quietly stops applying to a group of routes. None of these throw an error, so nothing but a deliberate re-test catches it.

Fixing it for real

  • Deny by default. Every route should require an explicit access-control decision to become reachable, rather than being open unless something blocks it - a default-deny middleware/framework convention makes “I forgot to add the check” fail closed instead of open.
  • Enforce access control server-side, always. Hiding a button or route in the frontend is a UX nicety, never a security boundary - assume every request reaches the server directly, because for an attacker, it will.
  • Centralize the policy. Scattering if (user.role === 'admin') checks across dozens of handlers guarantees some will be missed or drift out of sync - a single authorization layer (middleware, a policy/ability library, or a consistent service-layer convention) makes the rule enforceable and auditable in one place.
  • Scope every query to the requester rather than fetching broadly and filtering after the fact - the same “fail closed by construction” idea from the IDOR write-up, generalized to every resource, not just single-object lookups.
  • Allowlist writable fields explicitly on any handler that binds a request body to a model, instead of trusting the body’s shape.
  • Log and alert on authorization failures, not just authentication failures - a burst of 403s from one account, or a fast fan-out of requests across many object IDs, is a strong access-control-probing signal worth watching for even before a specific check is bypassed.
  • Treat every new role, endpoint, and refactor as an access-control event requiring a deliberate re-check, not an assumption that existing protections still apply.

The takeaway

Broken Access Control tops the list because it isn’t a single vulnerability class with a single fix - it’s every place an application forgets to ask “and is this identity allowed to do this?” after already confirming who’s asking. IDOR is the most common single expression of it, but the category is bigger: missing function-level checks, parameter-driven privilege escalation, mass assignment, permissive CORS. The throughline for all of them, and the actual fix, is the same: authorization is a deliberate, server-side, default-deny decision on every request - never an assumption inherited from the UI, and never optional.