Alice is logged in. Her token is valid, her session is fine, and every request she sends is authenticated. She changes 1001 to 1004 in a URL and reads Bob’s order, address and card digits. Then she calls /admin/users and gets every account in the system. Neither call needed a stolen password. This post teaches you the two bugs behind that story, broken object level authorization (BOLA) and broken function level authorization (BFLA): how to tell them apart, and how to test and fix each one. You do it in a browser console on a local Docker lab, with a curl route next to every step.
This is an authorized, local-only lab: everything runs on 127.0.0.1, the data is fake, and the tokens are placeholders such as demo-token-alice.
What are BOLA and BFLA?
BOLA is the API name for what web developers know as IDOR. Our IDOR to admin takeover post walks through one account-takeover chain in a web app. This post is about something different: telling an object check from a function check, and testing for both in an API.
Authentication is not authorization
Authentication answers "who are you?". Authorization answers "may you do this, to this?". An API can get the first right on every request and have no answer to the second. Both bugs live in that gap, and both sit high in the OWASP API Security Top 10 (2023): API1 is Broken Object Level Authorization, API3 is Broken Object Property Level Authorization, and API5 is Broken Function Level Authorization.
BOLA: the object is wrong
On OWASP’s API1 page, attackers manipulate the ID of an object, which "can be anything from sequential integers, UUIDs, or generic strings". "Every API endpoint that receives an ID of an object, and performs any action on the object" needs an object-level check. The caller may use the endpoint, just not on that object.
BFLA: the function is wrong
The API5 page describes sending legitimate API calls "to an API endpoint that they should not have access to". Here the caller may not use the endpoint at all, whichever object it touches. OWASP also warns: "Don’t assume that an API endpoint is regular or administrative only based on the URL path."
How to tell them apart
Ask two questions about a request that should have been refused:
- Would the same call be fine for a user with a different role (an admin)? If yes, the missing check is on the function: BFLA.
- Would the same call be fine for the owner of that object? If yes, the missing check is on the object: BOLA.
| BOLA (API1) | BFLA (API5) | BOPLA (API3, for contrast) | |
|---|---|---|---|
| Wrong thing | Which object | Which function | Which fields of an object (not covered by this lab) |
| Caller may use the endpoint? | Yes | No | Yes, and the object too |
| Typical request | GET /orders/1004 as alice |
GET /admin/users as alice |
GET /orders/1001 returns a field the caller should not see |
| Check that is missing (the question to ask) | May this user access this record? | May this role call this function? | Which properties may this user read or write? |
Why it matters
OWASP says BOLA failures "typically lead to unauthorized information disclosure, modification, or destruction of all data", and BFLA on administrative functions "may lead to data disclosure, data loss, or data corruption". In practice: one customer reads or changes everyone’s orders, and one ordinary account runs staff actions.
Lab setup: an orders API with two faces
One container on http://127.0.0.1:8100/ serves two copies of the same orders API, /api/vuln/... and /api/fixed/...; both answer 401 to a missing or unknown token. Seed data: users alice and bob (role user) and admin, and orders 1001–1007 (alice owns 1001–1003, bob 1004–1006, admin 1007). A top bar switches the user and the API; Reset data re-seeds SQLite.

The vulnerable code has the shape most real bugs have. The list is scoped to the caller, and the detail and admin functions are not:
def list_my_orders(ctx):
return 200, {"orders": db.orders_for(ctx.user["username"])} # correct: scoped to the caller
def get_order(ctx, order_id):
order = db.get_order(int(order_id))
if order is None:
return 404, {"error": "order not found"}
return 200, order # BOLA: never asks "is this YOUR order?"
def admin_list_users(ctx):
return 200, {"users": db.list_users()} # BFLA: no role check
The dispatcher goes straight from the route to the handler, with no policy in between:
route, args = found
return route.handler(ctx, *args)
Exploiting it: three missions
Mission 1: read, then change, someone else’s order (BOLA)
Open the Mission 1 · BOLA tab. Keep alice and vulnerable selected. Press List my orders and note her three orders. In Open one order by id type 1004 and press Open order. Then press Walk 1001–1008. Finally switch to bob, type 1002 in step 4 and press Cancel order; switch back to alice and open 1002.

Option 2: from the terminal
B=http://127.0.0.1:8100/api
curl -s -H "Authorization: Bearer demo-token-alice" $B/vuln/orders # her three orders
curl -s -H "Authorization: Bearer demo-token-alice" $B/vuln/orders/1004 # bob's order
for id in $(seq 1001 1008); do
curl -s -o /dev/null -w "$id %{http_code}\n" -H "Authorization: Bearer demo-token-alice" $B/vuln/orders/$id
done
curl -s -X POST -H "Authorization: Bearer demo-token-bob" $B/vuln/orders/1002/cancel # change alice's order
Show solution
Solution: Mission 1
The list is scoped, so the app looks safe, but GET /orders/1004 returns bob’s order with owner, ship_to and card_last4. The walk gets 200 for 1001–1007 (four belong to other users) and 404 for 1008, which shows where the ids end. Bob’s cancel returns 200 and alice’s order is cancelled: BOLA covers writes too. It is BOLA because the endpoint is meant for every customer; only the object is wrong.
Mission 2: call the admin functions as a normal user (BFLA)
Open Mission 2 · BFLA as alice on the vulnerable API. Press GET /admin/users, GET /admin/orders, then set the id to 1004 and press Refund. Tick "Front end hides admin buttons from non-admins": the buttons vanish. Open the Console tab and send GET /admin/users anyway.

Option 2: from the terminal
curl -s -H "Authorization: Bearer demo-token-alice" $B/vuln/me # role: user
curl -s -H "Authorization: Bearer demo-token-alice" $B/vuln/admin/users
curl -s -X POST -H "Authorization: Bearer demo-token-alice" $B/vuln/admin/orders/1004/refund
Show solution
Solution: Mission 2
All three calls return 200: the fake accounts with emails, every order, and bob’s order changed to refunded. The refund touches bob’s object, but that is not the bug: an admin may refund it, alice may not, so the missing check is on the role. Hiding the buttons changed nothing, since the same request works from the Console or curl. A UI is not a control; the server has to refuse.
Mission 3: flip to the fixed API and replay
Select fixed, press Reset data, and repeat Mission 1 (walk the ids as alice, cancel 1002 as bob) and Mission 2 (alice on /admin/users, which now gives 403). Then open Mission 3 · Fix & replay and press Run on both APIs.

Option 2: from the terminal
curl -s -i -H "Authorization: Bearer demo-token-alice" $B/fixed/orders/1004
curl -s -i -H "Authorization: Bearer demo-token-alice" $B/fixed/admin/users
python3 tools/matrix.py vuln # 14 requests, 9 violations
python3 tools/matrix.py fixed # 14 requests, 0 violations
Show solution
Solution: Mission 3
On the fixed API alice’s walk finds only her own orders; 1004–1008 all answer 404 {"error": "order not found"}, the same body as a missing id. Bob’s cancel of 1002 is 404 and changes nothing. /admin/users and the refund answer 403. The matrix gives the vulnerable API 9 violations and the fixed API 0. The 9 are 4 object-level rows, 3 BFLA rows and 2 rows on the route with no declared policy. Of the 4 object-level rows, 3 are plain BOLA (alice or bob reading or changing the other’s order) and 1 is policy-dependent: admin reading alice’s order on the customer route. In this lab customers and admins use different routes, so that route is owner-only. In your API an admin may well read any order. Write the rule down first; BOLA is a violation of your policy, not of a universal one.
Fixing it
Fix 1: check the object, in one place
OWASP’s API1 advice is "a proper authorization mechanism that relies on the user policies and hierarchy", applied in every function that uses client input to access a record. It also warns that comparing the session user id with the id parameter "isn’t a sufficient solution". Put the rule in one function and call it from every handler that loads an object:
def can_access_order(user, order, action):
if action in ("read", "cancel") and order["owner"] == user["username"]:
return True
return False # deny by default: only an explicit rule allows
def get_order(ctx, order_id):
order = load_order_for(ctx, order_id, "read") # loads, then asks can_access_order()
return (200, order) if order else NOT_FOUND # same 404 for "missing" and "not yours"
OWASP also suggests random, unpredictable ids; they make walking harder but are not a check, since a leaked id still works. Two design choices here are ours, not OWASP’s. First, the rule is owner-only, so admin is denied on /orders/{id}; a real API might allow admins and should write that down. Second, the fixed API returns the same 404 for "missing" and "not yours", so callers cannot use the status code to find which ids exist. 403 is also valid; what matters is that the object is not served. Use 404 if you want to hide that the id exists.
Fix 2: check the role, per route
Every route declares who may call it, and the dispatcher enforces that before any handler runs:
Route("GET", r"/admin/users", admin_list_users, ADMIN),
Route("POST", r"/admin/orders/(\d+)/refund", admin_refund, ADMIN),
if ctx.user["role"] not in route.roles: # function-level check
return 403, {"error": "forbidden"}
Fix 3: deny by default
OWASP API5 says the enforcement mechanism(s) "should deny all access by default, requiring explicit grants to specific roles for access to every function". In the lab /admin/export is a newer route with no roles declared:
Route("GET", r"/admin/export", admin_export), # roles not declared yet -> denied
if not route.roles: # deny by default
return 403, {"error": "forbidden"}
On the vulnerable API anyone can call it. On the fixed API even admin gets 403 until a developer decides who may use it: forgetting a policy fails closed.
Fix 4: make the matrix a test
OWASP says to write tests for the authorization mechanism and not to deploy changes that make them fail. The lab’s 14-request list is that test. This is a simplified version of it:
def test_fixed_api_follows_policy_for_every_matrix_row(self):
for actor, method, path, want, kind, note in matrix.CASES:
with self.subTest(actor=actor, req="%s %s" % (method, path)):
status, _ = request(self.base, "fixed", actor, method, path)
self.assertEqual(status, want, note)
It fails the day someone adds a route and forgets the check.
Re-testing the fixes
Mission 3 is the re-test: alice’s walk shows only her own three orders, and the matrix gives 9 violations versus 0. The lab’s 8 unit tests also check that missing and not-yours ids return identical responses and that a route with no declared roles is denied for admin too.


Vulnerable vs. fixed
| Vulnerable | Fixed | |
|---|---|---|
GET /orders/1004 as alice |
200, bob’s order |
404 order not found |
POST /orders/1002/cancel as bob |
200, alice’s order cancelled |
404, nothing changed |
GET /admin/users as alice |
200, all accounts |
403 forbidden |
| Route with no declared policy | open to every logged-in user | 403 for everyone |
| Where the rule lives | nowhere | route table + can_access_order() |
| Matrix result | 9 of 14 violate the policy | 0 of 14 |
Detecting this class of bug
Log one structured line per request with the actor, their role, the method and path, the status, and the owner of the object the handler loaded. The lab’s server prints exactly that (docker compose logs api):
{"ts":"2026-11-09T21:15:31Z","api":"vuln","actor":"alice","role":"user","method":"GET","path":"/orders/1004","status":200,"object_owner":"bob"}

Two rules catch most of it. Both fire on the vulnerable log and match nothing on the fixed one:
# R1 BFLA: a non-admin got a success on an admin path
docker compose logs --no-log-prefix api | jq -R -c 'fromjson? | select(.role!="admin" and (.path|startswith("/admin/")) and .status<300)'
# R2 BOLA: the actor got a success on an object that belongs to someone else
docker compose logs --no-log-prefix api | jq -R -c 'fromjson? | select(.object_owner and .object_owner!=.actor and .status<300 and (.path|startswith("/admin/")|not))'
On the fixed API attacks show up as refusals, so tools/detect.py adds two softer signals: one actor denied three or more times (probing), and one actor requesting five or more different /orders/<id> ids (enumeration). The rules need a written policy: "admin on /orders/1001" alerts here only because the lab policy says that route is owner-only.
Reviewer and tester checklist
- [ ] Every endpoint that takes an id: where is the check that this user may access this object, on reads and writes?
- [ ] Does every route declare its roles, and does a route with no declaration fail closed?
- [ ] Is the role taken from the server-side session or token, never from a request field?
- [ ] Does changing the HTTP method (
GETtoDELETE) or the version prefix reach an unchecked handler? - [ ] Is there a test that requests user B’s objects as user A, and admin functions as a normal user?
- [ ] Do logs record actor, role and object owner, and does anything alert on the two rules above?
Self-check
- Alice may call
GET /orders/{id}but reads Bob’s order. Which bug, and which check is missing? - A normal user calls
POST /admin/orders/1004/refundand it works. Why is this BFLA even though it touches Bob’s object? - Why does hiding admin buttons in the front end not fix BFLA?
- Why is a random order id helpful but not a fix?
- What does deny by default change when a developer adds an endpoint and forgets the policy?
Show answers
Answers
- BOLA: the object-level check.
- An admin may refund, alice may not, so the missing check is on the role, not on ownership.
- The API accepts the same request from any client; only the server can refuse.
- It makes guessing harder, but a leaked id still works. The server has to check access.
- The endpoint fails closed until roles are explicitly declared.
Key takeaways
- Authentication proves who you are. BOLA and BFLA are missing authorization: the request is authenticated and still should not succeed.
- BOLA: right function, wrong object. BFLA: wrong function for the role. Ask "would an admin be allowed?" and "would the owner be allowed?".
- Put the object rule in one policy function and the role rule in the route table; call both on every request.
- Deny by default, so a forgotten check fails closed. Write the policy down first, then test it as a matrix in CI.
- Log actor, role and object owner. A success on someone else’s object, or a non-admin success on
/admin/*, is the signal.
Lab: download and run it
bola-vs-bfla-api/
├── docker-compose.yml, Dockerfile, .dockerignore # one container, published on 127.0.0.1:8100 only
├── run.sh / run.ps1 # build, start, wait until healthy
├── app/
│ ├── server.py # HTTP, authentication (401), JSON request log, lab harness
│ ├── vulnerable.py # the bugs
│ ├── fixed.py # route policy table + ownership policy
│ ├── db.py, common.py, matrix.py # SQLite with fake data, routing, the 14-request matrix
│ └── ui/index.html # the console (plain HTML/CSS/JS, no CDN)
├── tools/matrix.py, tools/detect.py # terminal replay and detection rules
├── tests/test_authz_matrix.py # 8 tests, standard library only
└── README.md
Prerequisites: Docker with Compose v2, a browser, a free port 8100 on 127.0.0.1, and internet for the first build (it pulls python:3.12-slim). run.sh does not need curl; it waits for the container healthcheck. curl and Python 3.9+ on the host are optional, for the terminal options and tests. Tested with Docker 28.5.2, Compose 2.40.3 and Python 3.13 on Linux; run.ps1 was not run on Windows.
unzip bola-vs-bfla-api-lab.zip && cd bola-vs-bfla-api
./run.sh # Windows: powershell -ExecutionPolicy Bypass -File run.ps1
You should see Ready. Open http://127.0.0.1:8100/. Open it and work through the tabs. For the terminal options and tests:
python3 tools/matrix.py vuln # 9 violations
python3 tools/matrix.py fixed # 0 violations
python3 -m unittest discover -s tests -v # 8 tests, OK
Stop and clean up. This removes the container, the network and the lab image:
docker compose down --rmi all
docker images dhacker-bola-bfla/api # should list nothing
docker-compose.yml sets a custom image tag, and the Compose docs say --rmi local skips custom-tagged images, so use --rmi all. If the image is still listed, run docker rmi dhacker-bola-bfla/api:lab.
Everything binds to 127.0.0.1, runs as a non-root user in a read-only container, and uses fake data and placeholder tokens only. The /lab/* endpoints (reset, log, source) are lab tooling, not part of the API under test.