LEARN
Broken access control, explained
Broken access control means the app never asked whether this user should be allowed to do this. Here is how it shows up, why the interface cannot fix it, and how to hunt it down.
In short.
Broken access control happens when an app lets a logged-in user reach something they were not allowed to reach, because the app never checked. The request looks ordinary. The server simply does not ask whether this person may touch this record.
Almost every app answers two different questions. The first is who you are. The second is what you may do, and to which things. Authentication answers the first. Authorization answers the second.
When an app checks the first and skips the second, you get broken access control. The user is a real user. The session is real. The endpoint answers normally. It just never verified that this account is allowed to reach this record, this action, or this screen.
Three shapes
Horizontal: somebody else
A customer reaches another customer's data. Same role, same permissions, wrong record. This is the one that ends up in the news, because the data belongs to someone who never agreed to be a customer of a competitor.
Vertical: somebody above
A regular user reaches an administrative function. They change a role, read an internal queue, trigger a payout, or promote themselves. The permission model is real, it just was not applied on this route.
Object level: the wrong thing
The endpoint takes an identifier and returns whatever it finds. The permission check exists, but it only confirms that you may call this kind of endpoint. It never confirms that this particular object is yours. This is the most common of the three, and it has its own name. See IDOR and BOLA, explained.
Why the interface is not the check
The interface hides the admin menu. It hides the other tenant's record. It greys out the button. That is a usability decision, not a security control, because the interface runs on the client's machine and the caller does not have to use it.
The real check has to happen on the server, on every request, for the specific object being touched. If a rule lives only in the interface, it is not a rule. It is a suggestion.
What it looks like when it goes wrong
- A support agent opens a customer record by URL, and the record belongs to a different customer.
- A mobile app calls an endpoint the web app never exposed, and that endpoint has no permission check at all.
- An internal admin route is deployed and reachable, and the only thing in front of it is a check in the interface.
- A report or export runs a query that filters by user id for some filters and forgets it for the others.
- A service account with broad access is used to serve a user-facing request, so the user inherits its permissions.
- A webhook handler trusts the payload without checking which customer it belongs to, then writes it into the wrong tenant.
How to find it
You cannot grep for this. You have to map it, and the mapping is the work.
- List every route the product exposes, including the ones that only exist in the mobile app, the API and the admin tool.
- For each route, write down which object it touches and which action it performs. Reading an invoice and refunding an invoice are two different checks.
- Mark where the check happens. If a row has no check, or has a check that only asks about the role, it is a candidate.
- Test the negative case. Call it as a user in a second workspace, as a user with a lower role, and as a user of a different tenant. Not the happy path. The negative path.
- Change the object id to one you do not own. If the response comes back the same size and the same shape, you have found it.
How to fix it
- Filter by the owner in the query itself. Fetch by tenant and id together, never by id alone.
- Check the action, not only the role. Being allowed to read an invoice does not mean being allowed to refund one.
- Deny by default. If no rule matches, the request is not allowed.
- Check on the server, on every path, including bulk endpoints, exports, background jobs and webhooks.
- Write the negative test. The check that has no test is the one that is gone after the next refactor.