Access control is the rule that decides who may do what to which object, and it is the rule applications get wrong most often. The bug is rarely exotic. It is almost always a check that someone assumed was happening somewhere else. This post walks through five broken access control examples in an invented app, shows the exact request and response for each, and explains how to find and fix the same gaps in your own code.
Five broken access control examples
All five come from the same invented app, Acme Notes, a small team workspace where people write notes, invite colleagues, and export their data. Every request below is well formed and authenticated. Nothing is malformed and nothing is injected. That is the point: these requests are legal, and the server answers them anyway.
1. Reading another user’s object by changing an id
User A is signed in and opens one of their own notes.
GET /api/notes/4120
Authorization: Bearer tokenForUserA
200 OK
{ "id": 4120, "owner_id": 12, "title": "Q3 planning", "body": "..." }
They change one digit and send the same token.
GET /api/notes/4121
Authorization: Bearer tokenForUserA
200 OK
{ "id": 4121, "owner_id": 88, "title": "Salary review notes", "body": "..." }
Note 4121 belongs to owner 88. The token proved who the caller is. Nothing proved the caller owns this note. This is the horizontal case, one user reaching another user’s data at the same permission level.
2. Calling an admin route directly
The Acme Notes interface only draws the admin panel for accounts with the admin role, so a normal member never sees a link to it. The endpoint behind it is still live.
GET /api/admin/users?limit=500
Authorization: Bearer tokenForUserA
200 OK
{ "users": [ { "id": 12, "email": "a@example.com", "role": "member" }, ... ] }
This is the vertical case. The route checks that you are logged in and forgets to check what you are. Hiding the button removed the path a normal user would take to the endpoint, not the endpoint. Anyone who has watched the network tab of an admin account, or guessed the route, can call it.
3. Sending your own role in the request body
Acme Notes lets a workspace owner invite colleagues, and the invite endpoint accepts a role. The signup endpoint accepts the same object shape, because both write to the users table through one shared handler.
POST /api/signup
Content-Type: application/json
{ "email": "new@example.com", "password": "...", "role": "admin" }
201 Created
{ "id": 4310, "email": "new@example.com", "role": "admin" }
The server took a field from the client that only the server should ever set. No id was tampered with and no route was hidden. The app simply trusted an attribute that decides permission, which turns the account creation form into a promotion.
Every one of these requests is valid. The bug is not in what was sent, it is in the check the server did not run before answering.
4. A secondary path with no check on it
The direct fetch in example 1 gets fixed, and the team adds an ownership check to GET /api/notes/:id. The export job still runs the old query.
POST /api/exports
Authorization: Bearer tokenForUserA
{ "workspace_id": 7 }
200 OK
{ "job_id": "exp_91", "status": "queued" }
GET /api/exports/exp_91/download
Authorization: Bearer tokenForUserA
200 OK
notes.csv containing every note in workspace 7, including notes owned by other members
The background worker runs with service credentials so it can read across the whole workspace, and the request that started it was never checked against what user A is allowed to export. Search endpoints, list endpoints, report builders, and file downloads all fail this way. The check on the obvious route does not travel to the quiet ones.
5. Enforcement that lives in the browser
A member’s plan allows five notes. The interface disables the create button after the fifth, and the server never counts.
POST /api/notes
Authorization: Bearer tokenForUserA
{ "title": "Note 41", "body": "..." }
201 Created
Any rule enforced only by the interface is a suggestion. The same applies to fields the form marks as read only, to prices the client sends, and to steps a wizard performs in order. If the browser is the only thing enforcing it, a request sent outside the browser ignores it.
How to find these in your own app
Every example above is found the same way, by holding two accounts and asking whether one can reach the other’s things.
- Create two users and one admin. Note the object ids each one owns. Most of this testing is impossible with a single account.
- Swap ids across accounts. With A’s token, request B’s objects. A correct server answers
403 Forbiddenor404 Not Found. A200 OKcarrying B’s data is the finding. - Replay privileged routes with a normal token. Capture what an admin account calls, then send the same requests as a member.
- Add fields the client should not control. Try
role,is_admin,plan,owner_id, andworkspace_idin bodies that do not document them. - Follow the object into every other path. Search, list, export, download, webhook, and email notification. Each is a separate chance to leak the same record.
- Repeat per verb. Read access and write access fail independently, so test GET, then PATCH, PUT, and DELETE.
None of this is pattern matching. There is no payload to detect, because the request is exactly what a normal client sends. Finding these bugs means understanding what each object is and who is meant to own it, then testing that assumption directly. More on access control bugs is here.
How to fix them
The common cure is to make the ownership question part of the query rather than a separate step someone can forget.
def get_note(note_id, current_user):
note = db.notes.find_one(
id=note_id,
owner_id=current_user.id, # ownership is part of the lookup
)
if note is None:
return Response(status=404)
return Response(note)
- Scope every query to the caller by default in the data layer, so an unscoped lookup has to be written on purpose.
- Deny by default on routes. A new endpoint should be unreachable until someone states who may call it, rather than open until someone remembers to close it.
- Allowlist writable fields so a client can never set an attribute that grants permission.
- Give background jobs the caller’s permissions instead of service credentials, or check the request before the job is queued.
- Write one test per object route where user A asks for user B’s object and asserts a denial. That is what stops the bug returning after a refactor.
Broken access control is a logic bug, not a string in a payload, which is why it survives tools that look for known bad input and why it keeps topping the lists of what actually gets exploited. Finding it means knowing what an object is, who should own it, and proving the server agrees, which is exactly the kind of assumption an autonomous researcher that tests application logic is built to check. Read more about how UnboundCompute works.
Frequently asked questions
What is an example of broken access control?
The clearest example is changing an id in a request. A signed in user calls GET /api/notes/4120 for their own note, changes it to GET /api/notes/4121 with the same token, and the server returns a note owned by someone else. The token proved who the caller is, and nothing proved the caller owns that object. Other common examples are calling an admin route with a normal account, sending a role field the server should set itself, and an export job that reads across a whole workspace.
What is the difference between horizontal and vertical access control bugs?
Horizontal means reaching another user’s data at the same permission level, such as one member reading another member’s note. Vertical means gaining a higher permission level, such as a member calling an admin only endpoint or setting their own role to admin during signup. They are found differently: horizontal needs two accounts of the same type, vertical needs a low privilege account replaying what a privileged account does.
Why do scanners miss broken access control?
Because there is no payload to match. The request is exactly what a normal client sends, every field has the right type, and the session is valid. A scanner comparing traffic against a list of known bad strings sees nothing wrong, because nothing is wrong with the string. Deciding that a response is a bug requires knowing who is meant to own the object, which lives in the intent of the application rather than in its code.
How do I test my app for broken access control?
Create two normal users and one admin, then note which objects belong to each. While signed in as user A, request user B’s objects and confirm the answer is 403 Forbidden or 404 Not Found. Replay every request an admin makes using a member token. Add fields such as role, is_admin, and owner_id to bodies that do not document them. Then repeat the whole exercise on search, list, export, and download paths, which are checked far less often than the direct fetch.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
