API Security · intermediate · ~11 min

Broken Object Level Authorization (BOLA)

**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.

Overview

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.

Why it matters

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.

  • It is common. OWASP ranks it #1 in the API Security Top 10 because the flaw appears whenever a developer authenticates the caller but forgets to authorize the specific object. That is easy to overlook and easy to repeat across dozens of endpoints.
  • It scales into breaches. One predictable or leaked ID plus a loop can enumerate an entire customer table. Real breaches of exposed personal and financial data have come from exactly this pattern.
  • It is cheap to exploit and cheap to fix. No specialized tooling is required to trigger it, which is why attackers try it first. The fix — an ownership check on every access — is well understood, so a professional who can find and remediate and verify BOLA delivers real value.
  • Testing object references is the best use of your time. During an authorized API assessment, systematically replaying object requests across two accounts finds more real, high-severity issues than almost any other single activity.

Core concepts

Concept 1 — Object level authorization

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.

Concept 2 — BOLA / IDOR

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.

Concept 3 — Where object IDs appear

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.

Concept 4 — Authentication vs. authorization (the root cause)

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.

Concept 5 — Deny by default

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.

Threat model

        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.

  1. What asset is protected here, and where is the trust boundary? (Per-user objects in the database; the boundary is the HTTP entry point — the client-supplied ID is untrusted even with a valid token.)
  2. What insecure assumption causes BOLA? (That a valid authentication token implies the caller is authorized for whichever object ID they send.)
  3. Which log entry would reveal an attempt? (Repeated requests from user B for object IDs owned by other users, or a spike of 403 "authorization denied" events for sequential IDs from one identity.)
  4. Why do you only test this in an authorized lab? (Replaying requests to read another person's data on a system you do not own or have written permission to test is illegal and unethical; labs use accounts and data you control.)

Syntax notes

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.
  • The ownership comparison (account.owner_id != current_user.id) is the object-level authorization check. It must sit between loading the object and returning it.
  • Returning 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.

Lesson

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.

The pattern

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.

How to test

  • Authenticate as user A. Capture the requests and note every object ID.
  • Replay those requests as user B (or unauthenticated). If B receives A's objects, that is BOLA.
  • Try sequential IDs, leaked UUIDs, and IDs taken from other responses.

Why it persists

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.

The fix

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.

Code examples

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.

1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

# 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.

2) SECURE fix — add the mandatory ownership check

# 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"])

3) VERIFY — prove the fix rejects bad input and accepts good input

# 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.

Line by line

Walkthrough of the secure endpoint and its tests.

  1. 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.
  2. if account is None: return 404 — handle a missing object before any ownership logic, so the check never runs on None.
  3. 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.
  4. On mismatch: log.warning(... decision=deny) then return 403. Logging first records the security decision; 403 denies by default.
  5. On match: 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.

Common mistakes

Mistake 1 — Trusting an identity from the request body.

  • Wrong: reading user_id from the JSON body and using it as the caller's identity ({"userId": 5002}).
  • Why wrong: the attacker controls the body, so they simply claim to be anyone.
  • Corrected: derive identity only from the verified token/session on the server; ignore any identity in the request payload.
  • Recognise/prevent: grep for the identity variable and confirm it is never assigned from request input.

Mistake 2 — Relying on opaque/random IDs as the control.

  • Wrong: switching to UUIDs and considering BOLA solved.
  • Why wrong: UUIDs leak via logs, referrer headers, other API responses, and shared links; unguessability is not authorization.
  • Corrected: keep the server-side ownership check regardless of ID format. Opacity is defense in depth, not the control.
  • Recognise/prevent: ask "if the attacker already knows this ID, what stops them?" The answer must be a server check.

Mistake 3 — Checking only the obvious path parameter.

  • Wrong: authorizing /orders/{id} but ignoring an ID passed in a query string, body, or header on another endpoint.
  • Why wrong: IDs enter through multiple channels; one unguarded channel is enough.
  • Corrected: apply the ownership check wherever an object reference is accepted, and prefer a shared helper so no endpoint forgets.
  • Recognise/prevent: enumerate every object-ID input during review and map each to its check.

Mistake 4 — Authorizing at creation but trusting the ID forever after.

  • Wrong: verifying ownership when the object is created, then trusting later GET/PATCH/DELETE calls.
  • Why wrong: every subsequent access is a fresh, attacker-controlled request.
  • Corrected: run the check on every access, for every verb.

Mistake 5 — Confusing 'passing a scanner' with 'secure'.

  • Wrong: concluding the API is safe because an automated scanner found nothing.
  • Why wrong: scanners rarely have two valid accounts and rarely understand which object belongs to whom, so they miss most BOLA.
  • Corrected: test with two real accounts and manual cross-user replay; a clean scan does not prove the object check exists.

Debugging tips

When your fix seems not to work (attack still succeeds):

  • Confirm current_user_id() truly comes from the verified token, not from a header/body the test client sets directly.
  • Check the comparison is != on the owner field, and that owner_id is populated for every record (a None owner can accidentally match).
  • Make sure the check runs before any early return/data serialization — a return above the check bypasses it.

When legitimate users get 403 (false positive):

  • Compare types: "1" != 1 is always true in Python, so a string ID from the URL vs. an integer owner will wrongly deny. Normalize types.
  • For shared resources, confirm your policy accounts for legitimate co-owners or admins, not just a single owner.

Questions to ask when an authorization test fails:

  • Which exact value did the attacker control, and which came from the server?
  • Is there any code path to the database that skips the check?
  • Does the endpoint leak object existence via different status codes (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.

Memory safety

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:

  • Timestamp (with timezone).
  • Source: authenticated user/subject id, source IP, user-agent.
  • Resource: the endpoint and the object id requested.
  • Result and security decision: allow or deny, and the reason (e.g., owner_mismatch).
  • Correlation id / request id, so related events can be tied together.

Never log:

  • Passwords, tokens, session cookies, API keys, or private keys.
  • Full payment card numbers (PANs) — mask them.
  • Unneeded PII or the sensitive object contents themselves — log the id and decision, not the bank statement.

Events that signal abuse:

  • One identity requesting many object ids it does not own in a short window.
  • A burst of deny/403 events walking sequential ids (5001, 5002, 5003 ...).
  • Access to object ids that never appeared in that user's own responses (they must have come from elsewhere).

How false positives arise:

  • Legitimate admins or support staff accessing many accounts by design — model these roles explicitly so they are not flagged as attackers.
  • Shared or delegated resources (a joint account, a team folder) where more than one user is genuinely authorized.
  • Retries and client bugs that resend requests. Tune alert thresholds and whitelist known service accounts to keep signal high.

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.

Real-world uses

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:

  • Validate that identity comes from the verified token, never the request body.
  • Deny by default: no data path may reach the database without passing the ownership check.
  • Test with two accounts, not one — you cannot see cross-user access with a single login.
  • Log every allow/deny decision (id + result), never secrets.

Advanced habits:

  • Centralize authorization in a policy layer (e.g., an ABAC/RBAC engine or a shared authorize(user, action, object) helper) so no endpoint can forget the check.
  • Add automated cross-user authorization tests to CI, so a regression that removes a check fails the build.
  • Apply least privilege and consistent object-not-found handling to avoid leaking existence.
  • Monitor for enumeration patterns and rate-limit object access.

Authorization checklist before any lab or test:

  • You own the system, or you have explicit written permission and a defined scope.
  • Testing runs on localhost, a container, an intentionally-vulnerable VM, or a CTF — never a third party's production system.
  • You use test accounts and synthetic data, not real customers' records.
  • You have a rollback/cleanup plan.

Practice tasks

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.

  • Objective: inventory where object ids enter a small lab API.
  • Requirements: list every endpoint and, for each, whether an object id arrives via path, query, body, or header.
  • Output: a table of endpoint → id location.
  • Constraints: documentation only, no attack yet.
  • Hints: headers and JSON bodies are the easy ones to miss.
  • Concepts: where object ids appear; attack surface.

Beginner 2 — Reproduce BOLA in the intentionally-vulnerable lab app.

  • Objective: using two of your own test accounts, show that account A can read account B's object.
  • Requirements: capture A's request, replay it as B with A's id; record status code and whether data leaked.
  • Input/Output: input = A's id + B's token; output = observed response.
  • Constraints: lab only; use synthetic data.
  • Hints: keep both sessions open to compare against a legitimate baseline.
  • Concepts: cross-user replay; authentication vs authorization.
  • Defensive conclusion: write a one-line finding and note the fix you will apply.

Intermediate 1 — Apply the ownership check.

  • Objective: add a server-side, deny-by-default ownership check to the vulnerable endpoint.
  • Requirements: identity comes from the verified token; compare against the object's owner; return 403 on mismatch.
  • Constraints: do not rely on changing ids to UUIDs as the fix.
  • Hints: put the check between loading the object and returning it.
  • Concepts: object level authorization; deny by default.
  • Defensive conclusion: re-run the Beginner-2 replay and confirm it now returns 403.

Intermediate 2 — Add authorization logging and a detection rule.

  • Objective: log each allow/deny with user id, object id, and decision, then define an alert.
  • Requirements: never log tokens, cookies, or object contents; write a rule that flags one identity hitting many non-owned ids.
  • Constraints: redact/omit secrets; keep only id + decision.
  • Hints: sequential denied ids from one user is the classic signal.
  • Concepts: detection and logging; false positives.
  • Defensive conclusion: trigger the alert with a lab replay, then confirm normal use does not trip it.

Challenge — Multi-channel BOLA and a shared control.

  • Objective: find and fix a BOLA where the id is in a query string or header (not the path), and prevent recurrence.
  • Requirements: demonstrate the flaw in the lab, then centralize the ownership check in one reusable authorize(user, action, object) helper used by every relevant endpoint; add a CI test that fails if any endpoint skips it.
  • Constraints: lab only; deny by default; type-normalize ids to avoid false positives.
  • Hints: a shared helper is how mature codebases stop this from coming back.
  • Concepts: multi-channel object ids; policy centralization; regression testing.
  • Defensive conclusion: show the pre-fix cross-user access, the post-fix 403 across all channels, and the passing CI authorization test.

Summary

  • BOLA is the #1 API risk: an endpoint accepts an object id and returns/acts on it without verifying the caller owns that specific object. It is IDOR at API scale, and a single altered id can cause mass data exposure.
  • Root cause: authentication (valid token) is checked, but per-object authorization is missing. A token proves who you are, not what you may access.
  • Object ids hide everywhere: path, query string, body, and headers — check them all.
  • Find it by cross-user replay: send user A's request as user B in an authorized lab; if B gets A's data, that is BOLA.
  • Fix it correctly: a mandatory, server-side, deny-by-default ownership check on every access, with identity taken from the verified token — never the request body. Opaque/random ids are defense in depth, not the control.
  • Verify the fix: tests must show cross-user requests are rejected (403, no leaked data) and legitimate requests still succeed (200).
  • Detect it: log every allow/deny decision with user id, object id, and result; never log tokens, cookies, or the object contents. Watch for one identity walking many non-owned ids.
  • Remember: passing a scanner does not prove the check exists, and nothing is ever "completely secure" — test with two real accounts and centralize the authorization check so no endpoint forgets it.

Practice with these exercises