Broken Access Control: IDOR and Privilege Escalation

Intermediate
12 min

Broken Access Control: IDOR and Privilege Escalation

Broken Access Control sits at number one in the OWASP Top 10 because it is both common and devastating: the application authenticates the user correctly and then fails to check whether this user may perform this action on this record. No exploit tooling is needed; the attacker simply changes a number in a URL.

In this lesson you will learn the difference between authentication and authorization, the most frequent access control bugs (IDOR, missing function-level checks, privilege escalation), and a deny-by-default pattern for Express that makes the checks impossible to forget.

Authentication Is Not Authorization

Authentication answers "who are you?" Authorization answers "are you allowed to do this?" Most frameworks make the first easy and leave the second entirely to you. A request that passes login middleware is not yet safe; every handler that touches data owned by someone must still decide whether the caller may touch it.

Access control decisions must be made server-side, on every request, using the identity the server established. Hidden buttons, disabled menu items and client-side route guards are user-experience features, not security.

IDOR: Insecure Direct Object Reference

IDOR is the classic bug. A route like GET /invoices/1042 fetches the invoice by ID and returns it. If the query does not include the owner, changing 1042 to 1043 returns someone else's invoice.

javascript
// Vulnerable: any authenticated user can read any invoice const invoice = await Invoice.findById(req.params.id);
javascript
// Fixed: the ownership condition is part of the query const invoice = await Invoice.findOne({ _id: req.params.id, ownerId: req.user.id });

Putting the ownership condition inside the query is better than fetching first and checking afterwards: it cannot be skipped by a later refactor, and it returns 404 for both "does not exist" and "not yours", which avoids leaking which IDs are valid. Random UUIDs instead of sequential IDs make guessing harder, but they are not a substitute for the check.

Missing Function-Level Access Control

The second pattern is an endpoint that exists for administrators but is not protected because the link to it is only shown to administrators. Attackers find such routes in JavaScript bundles, API documentation and by guessing (/admin, /api/users, /export).

The fix is structural: group privileged routes under a router and apply the role check to the router itself, as in the sample code. The check then covers every route added later, including ones a future developer forgets to think about. Apply the same idea to HTTP methods: a GET that is public does not mean the DELETE on the same path is.

Privilege Escalation Through Data

Even with route checks in place, users can escalate by sending fields they should not control:

javascript
// Vulnerable: mass assignment lets a user set role: "admin" or ownerId await User.findByIdAndUpdate(req.user.id, req.body); // Fixed: pick only the fields a user may edit const { displayName, avatarUrl } = req.body; await User.findByIdAndUpdate(req.user.id, { displayName, avatarUrl });

Related traps: trusting a userId sent in the body instead of the one from the session, and accepting role claims from a token the client can forge. Anything that affects privilege comes from the server's records, never from the request.

A Deny-by-Default Checklist

| Rule | Why | |---|---| | Every route requires authentication unless explicitly marked public | Forgetting a check fails closed, not open | | Ownership or tenancy is part of every data query | Cannot be bypassed by a missing if | | Roles are read from the server-side user record | Client-supplied roles are worthless | | Privileged routers carry their own middleware | New routes inherit protection | | Access control failures are logged with user and resource IDs | Enumeration attempts become visible | | Tests assert 403 for the wrong user, not only 200 for the right one | The negative case is the security case |

Quick Quiz
Question 1 of 2

A user changes `/orders/500` to `/orders/501` and sees another customer's order. Which vulnerability is this?

Key Takeaways

  • Authentication proves identity; authorization must still be checked on every request, server-side.
  • IDOR is fixed by scoping every query to the owner or tenant, not by hiding IDs.
  • Protect privileged routes at the router level so new routes inherit the check.
  • Never accept role, owner or user ID fields from the request body; pick allowed fields explicitly.
  • Deny by default, log failures, and write tests for the forbidden case.

Next lesson: Insecure Design and Secure-by-Design Principles — why some flaws cannot be patched in code because they were designed in.

Broken Access Control: IDOR and Privilege Escalation - Cyber Security | CodeYourCraft | CodeYourCraft