API Security · intermediate · ~11 min
**What you will learn** - Explain what BOLA (Broken Object Level Authorization) is and why it is the number one risk on the OWASP API Security Top 10. - Locate every place an object identifier can enter an API request: URL path, query string, request body, and headers. - Test for BOLA safely in an authorized lab using cross-user replay (send user A's request as user B). - Write and verify the correct fix: a mandatory, server-side, per-object ownership check that denies by default. - Prove the fix works with tests that show bad requests are rejected and legitimate requests still succeed. - Log authorization decisions so BOLA attempts are detectable, without logging secrets.
Security objective. The asset you are protecting is per-user data and resources exposed through an API — bank statements, medical records, orders, messages, files. The threat is an authenticated (or sometimes anonymous) caller reading or modifying another user's object simply by changing an ID in the request. In this lesson you will learn to detect that flaw in an authorized lab and, more importantly, to prevent it with a server-side authorization check you can verify.
What BOLA is. BOLA (Broken Object Level Authorization) is IDOR — Insecure Direct Object Reference — applied to APIs. An endpoint receives an object ID and uses it to fetch or change a record without checking that the caller is allowed to access that specific object. Swap 5001 for 5002 in the path and the API hands you someone else's data.
Why it is number one. APIs are object-centric: nearly every endpoint reads or writes an object identified by an ID. When the ownership check is missing, a single altered identifier causes mass data exposure — no exploit chain, no memory corruption, just a changed number.
How this connects to your prereqs. In The API attack surface you mapped where an API accepts input (paths, query strings, bodies, headers) — those are exactly the places object IDs hide. In Broken access control and IDOR you learned that authentication (proving who you are) is not the same as authorization (deciding what you may touch). BOLA is that gap at API scale: the token is valid, but the per-object authorization check was never written. The next lesson, BFLA (Broken Function Level Authorization), extends the same idea from which object to which operation.
In authorized professional work — a scoped API penetration test, a secure code review, or building an API yourself — BOLA is the highest-yield thing to look for and the most damaging thing to miss.
Definition. Object level authorization is the server-side decision: "Is this authenticated caller allowed to access this specific object?" It is made per request, per object.
Plain explanation. Every record in a system has an owner or an allowed audience. Authorization is the gate that checks the caller against that ownership before returning or changing the record.
How it works. The server takes the caller's identity (from a verified token or session) and the requested object's owner (from the database), and compares them — or evaluates a policy. If they do not match, it denies.
When / when not. You need this on every endpoint that accepts an object identifier. You do not need it for truly public, non-owned data (e.g., a public product catalog) — but be sure the data really is public.
Pitfall. Checking authorization only in the UI or only when the object is created, then trusting the ID forever after. The check must run on every access.
Definition. BOLA is the vulnerability that exists when object level authorization is missing or wrong, so a caller can access objects they do not own by supplying that object's ID.
Plain explanation. The server takes an ID straight from the request and fetches the record with no ownership gate. "Direct object reference" means the client-supplied value maps directly to a stored object.
How it works. GET /api/v1/accounts/5001/statements returns your data. Change 5001 to 5002 and, if there is no check, you receive another user's statements.
When / when not. It is a bug whenever the object is owned/restricted. It is not a bug to expose an ID for genuinely public data.
Pitfall. Assuming random/opaque IDs (UUIDs) make you safe. They raise the effort to guess, but IDs leak — through referrals, logs, other API responses, shared links — so opacity is not a control.
Object identifiers can enter a request through several channels, and testers must check all of them:
| Location | Example |
|---|---|
| URL path | GET /api/v1/orders/5002 |
| Query string | GET /api/v1/export?accountId=5002 |
| Request body | PATCH /api/v1/profile with {"userId": 5002} |
| Header | X-Account-Id: 5002 |
Pitfall. Fixing the obvious path parameter but leaving an ID in a JSON body or custom header unchecked.
A valid token proves who you are. It says nothing about which objects you may touch. BOLA persists because teams verify the token and stop there. Authentication is necessary but never sufficient for object access.
The secure posture is to refuse access unless an explicit ownership/policy check passes. If a code path can reach the database without passing the check, treat that as a finding.
UNTRUSTED | TRUSTED (server)
---------------------------------- | ----------------------------------
|
[ Authenticated user B ] | Entry point: GET /accounts/{id}/statements
| valid token for B | |
| supplies id = 5001 (A's) | v
+---- HTTP request -------------+--> [ Auth check: token valid? ] --- yes -->
| | |
=== TRUST BOUNDARY === | v v
| [ MISSING: does token-owner == object-owner? ]
| | |
| (present) (absent = BOLA)
| | |
| v v
| [ 403 Forbidden ] [ DB: SELECT * FROM statements
| WHERE account_id = 5001 ]
| |
| v
| A's data returned to B
Assets protected: per-user objects (statements, orders, profiles) in the DB
Trust boundary: the HTTP entry point — everything from the client is untrusted,
including the object ID, even when the token is valid
Entry points: any endpoint that accepts an object ID (path/query/body/header)
Knowledge check.
The core of the fix is one server-side comparison run on every object access: does the object's owner match the authenticated caller? Everything else is plumbing.
# Pseudocode / Flask-style annotation of the mandatory check.
# current_user comes from the VERIFIED token, never from the request body.
@app.get("/api/v1/accounts/<int:account_id>/statements")
@requires_auth # 1) authentication: token is valid
def get_statements(account_id):
account = db.get_account(account_id) # 2) load the object
if account is None:
abort(404) # do not reveal existence
if account.owner_id != current_user.id: # 3) OBJECT-LEVEL AUTH
log_authz_denied(current_user.id, account_id)
abort(403) # deny by default
return jsonify(account.statements) # 4) only now, return data
Key points in the annotation:
current_user.id is derived from the verified token on the server, not read from a header or body the client controls.account.owner_id != current_user.id) is the object-level authorization check. It must sit between loading the object and returning it.404 rather than 403 for objects the user may not even know exist can further reduce information leakage; pick one policy and apply it consistently.BOLA (OWASP API number 1) is IDOR at API scale.
IDOR means Insecure Direct Object Reference: the server takes an ID straight from the request and uses it to fetch a record, without confirming the caller is allowed to see that record.
With BOLA, the endpoint accepts an object ID and returns or acts on it without verifying the caller owns it.
GET /api/v1/accounts/5001/statements ← your account
GET /api/v1/accounts/5002/statements ← someone else's — returned
Object IDs appear in paths, query parameters, JSON bodies, and headers.
APIs are object-centric, so BOLA is both the most common and the most impactful API bug. It is mass data exposure from a single altered identifier.
Developers check authentication (a valid token) but forget per-object authorization.
A valid token proves who you are. It does not say which records you may see.
On every object access, verify that the authenticated user is authorized for that specific object. Do this server-side, and deny by default.
Random or opaque IDs help, but they are not a control — UUIDs can leak. The authorization check is mandatory.
The example below shows the same endpoint three ways: an insecure version, the secure fix, and a verification test. It uses Flask-style Python because it reads clearly; the concept applies to any framework.
# vulnerable_api.py (LAB ONLY)
from flask import Flask, jsonify, g
app = Flask(__name__)
# Fake data store: account_id -> {owner, statements}
ACCOUNTS = {
5001: {"owner_id": 1, "statements": ["A: -$40 groceries"]},
5002: {"owner_id": 2, "statements": ["B: -$900 rent"]},
}
def current_user_id():
# Assume auth middleware set this from a verified token.
return g.user_id
@app.get("/api/v1/accounts/<int:account_id>/statements")
def get_statements(account_id):
account = ACCOUNTS.get(account_id)
if account is None:
return jsonify(error="not found"), 404
# BUG: no ownership check. Any authenticated caller can read any account.
return jsonify(statements=account["statements"])
Logged in as user 1 (owner of 5001), a request to /api/v1/accounts/5002/statements returns user 2's rent statement. That is BOLA.
# secure_api.py
from flask import Flask, jsonify, g
import logging
app = Flask(__name__)
log = logging.getLogger("authz")
ACCOUNTS = {
5001: {"owner_id": 1, "statements": ["A: -$40 groceries"]},
5002: {"owner_id": 2, "statements": ["B: -$900 rent"]},
}
def current_user_id():
return g.user_id # from the VERIFIED token, not the request
@app.get("/api/v1/accounts/<int:account_id>/statements")
def get_statements(account_id):
account = ACCOUNTS.get(account_id)
if account is None:
return jsonify(error="not found"), 404
# OBJECT-LEVEL AUTHORIZATION: deny by default unless the caller owns it.
if account["owner_id"] != current_user_id():
log.warning("authz_denied user=%s account=%s decision=deny",
current_user_id(), account_id)
return jsonify(error="forbidden"), 403
log.info("authz_ok user=%s account=%s decision=allow",
current_user_id(), account_id)
return jsonify(statements=account["statements"])
# test_authz.py — run: pytest test_authz.py
import pytest
from flask import g
import secure_api
@pytest.fixture
def client():
secure_api.app.testing = True
return secure_api.app.test_client()
def as_user(uid):
# Simulate the verified-token middleware setting the identity.
@secure_api.app.before_request
def _set():
g.user_id = uid
def test_owner_can_read_own_account(client):
as_user(1) # user 1 owns account 5001
r = client.get("/api/v1/accounts/5001/statements")
assert r.status_code == 200 # GOOD input accepted
assert b"groceries" in r.data
def test_user_cannot_read_others_account(client):
as_user(1) # user 1 does NOT own 5002
r = client.get("/api/v1/accounts/5002/statements")
assert r.status_code == 403 # BAD (cross-user) input rejected
assert b"rent" not in r.data # no data leaks in the body
Expected result. Against vulnerable_api.py, a cross-user request returns 200 with another user's data. Against secure_api.py, test_owner_can_read_own_account passes with 200 and test_user_cannot_read_others_account passes with 403 and no leaked data. The two tests together are your mitigation verification: they demonstrate the check rejects the attack and permits legitimate use.
Walkthrough of the secure endpoint and its tests.
account = ACCOUNTS.get(account_id) — the object is loaded by the client-supplied ID. At this instant nothing has been authorized; the ID is still untrusted input.if account is None: return 404 — handle a missing object before any ownership logic, so the check never runs on None.account["owner_id"] != current_user_id() — the object-level authorization gate. current_user_id() returns the identity from the verified token; account["owner_id"] is the record's true owner from the store. This comparison is the entire defense.log.warning(... decision=deny) then return 403. Logging first records the security decision; 403 denies by default.log.info(... decision=allow) then return the data. Only a caller who passed the gate reaches this line.Trace of the two tests:
| Test | current_user_id() |
account_id |
owner_id |
Comparison | Result |
|---|---|---|---|---|---|
| owner reads own | 1 | 5001 | 1 | 1 != 1 is false |
allow → 200 |
| cross-user read | 1 | 5002 | 2 | 1 != 2 is true |
deny → 403 |
The key insight: the only value the attacker controls is account_id. The identity (current_user_id()) comes from the server side, so the attacker cannot fake ownership by changing the request.
Mistake 1 — Trusting an identity from the request body.
user_id from the JSON body and using it as the caller's identity ({"userId": 5002}).Mistake 2 — Relying on opaque/random IDs as the control.
Mistake 3 — Checking only the obvious path parameter.
/orders/{id} but ignoring an ID passed in a query string, body, or header on another endpoint.Mistake 4 — Authorizing at creation but trusting the ID forever after.
GET/PATCH/DELETE calls.Mistake 5 — Confusing 'passing a scanner' with 'secure'.
When your fix seems not to work (attack still succeeds):
current_user_id() truly comes from the verified token, not from a header/body the test client sets directly.!= on the owner field, and that owner_id is populated for every record (a None owner can accidentally match).return/data serialization — a return above the check bypasses it.When legitimate users get 403 (false positive):
"1" != 1 is always true in Python, so a string ID from the URL vs. an integer owner will wrongly deny. Normalize types.Questions to ask when an authorization test fails:
403 vs 404)?Useful lab tools. In an authorized lab, an intercepting proxy (e.g., OWASP ZAP or Burp Suite Community) lets you capture user A's requests and replay them with user B's token so you can watch the status code and body change. Keep two browser sessions/accounts open so you always have a legitimate baseline to compare against.
Security & safety — detection and logging for BOLA.
Authorization decisions are only useful for defense if you can see them. Log every object-access decision so abuse is detectable and legitimate use is auditable.
Log for each object access:
allow or deny, and the reason (e.g., owner_mismatch).Never log:
Events that signal abuse:
deny/403 events walking sequential ids (5001, 5002, 5003 ...).How false positives arise:
Aggregate these logs centrally and alert on the abuse patterns above; the same deny records that stop the request also give your monitoring the data it needs.
Authorized real-world use case. During a scoped, written-authorized API penetration test of a client's banking app, a tester is given two low-privilege test accounts. They log in as account A, capture a request like GET /api/v1/accounts/{id}/statements, then replay it with account B's token and A's object id. If B receives A's statements, that is a high-severity BOLA finding: mass exposure of financial data from a single altered id. The tester documents safe reproduction, evidence (redacted), impact, and the remediation (server-side ownership check), then retests after the fix.
Beginner best-practice habits:
Advanced habits:
authorize(user, action, object) helper) so no endpoint can forget the check.Authorization checklist before any lab or test:
All tasks are lab-only: run them against your own local API (localhost/container), with accounts and data you created. Do not test any system you do not own or lack written authorization to test. Each task ends by remediating and verifying.
Beginner 1 — Map the object references.
Beginner 2 — Reproduce BOLA in the intentionally-vulnerable lab app.
Intermediate 1 — Apply the ownership check.
Intermediate 2 — Add authorization logging and a detection rule.
Challenge — Multi-channel BOLA and a shared control.
authorize(user, action, object) helper used by every relevant endpoint; add a CI test that fails if any endpoint skips it.