Broken Access Control (BAC) is, according to OWASP data, the most prevalent web security flaw in existence. It has held the #1 spot in the OWASP Top 10 (A01) for years — and, in the A01:2025 edition, 100% of the applications tested exhibited some form of broken access control. The reason is simple: there’s nothing exotic about “letting someone see what they shouldn’t.” It’s the category that encompasses every case where the application authenticates correctly who you are but fails to decide what you’re allowed to do. The server confirms your session, sees your request to access order #1234, and hands it over — without ever asking whether that order is yours.

IDOR (Insecure Direct Object Reference) is the most famous member of this family. It’s the flaw that swaps an id in the URL and returns someone else’s data. It matters because it’s trivial to exploit (no payload, exotic tool, or race condition required — just a browser and curiosity), hard to detect with automated scanners (the request is “valid”: authenticated and well-formed), and devastating in impact: mass data leakage, modification of other people’s records, and, in the worst case, becoming an administrator. This article is your pocket reference to cite in a report and the remediation guide for whoever writes the code.

The taxonomy: BAC, IDOR, BOLA, BFLA

These acronyms cause confusion because they describe the same problem at different layers. Let’s cut through them:

  • Broken Access Control (BAC) — the umbrella. Any failure in enforcing authorization rules. Everything below is a subtype of BAC. It’s the A01:2025 category of the OWASP Top 10 (which, in this edition, also absorbed SSRF).
  • IDOR (Insecure Direct Object Reference) — the application exposes a direct reference to an internal object (a database id, a file name) and doesn’t verify whether the session’s user is entitled to that specific object. It’s the most common root cause of object-level BAC.
  • BOLA (Broken Object Level Authorization) — the #1 of the OWASP API Security Top 10 (API1:2023). It’s exactly the same idea as IDOR, just expressed in the vocabulary of the API world. IDOR and BOLA describe the same flaw — use the term that fits your audience (IDOR for classic web, BOLA in an API context).
  • BFLA (Broken Function Level Authorization) — this isn’t about which object, but about which function (API5:2023 in the API Top 10). Here the problem is accessing an endpoint or operation that should be restricted to a higher role (e.g., a regular user calling POST /api/admin/users). This is what typically leads to vertical escalation.

Keep this distinction in mind, because it defines the escalation axis:

  • Horizontal escalation — you access data belonging to another user at the same level. Swapping userId=1001 for userId=1002. Typically an IDOR/BOLA.
  • Vertical escalation — you gain higher privileges: you become an admin, access the management panel, execute administrative functions. Typically a BFLA, or an IDOR combined with mass assignment (e.g., setting "role": "admin" on your own profile).

The anatomy of the attack

The vulnerability lives in a single line of flawed reasoning: fetching the object by the identifier the client supplied, and returning it without checking ownership.

Picture an order-detail endpoint. The user Alice (authenticated session) opens her order:

GET /api/orders/1042 HTTP/1.1
Host: store.example.com
Cookie: session=<alices-valid-token>

On the server, the vulnerable code looks like this:

@app.get("/api/orders/<int:order_id>")
@login_required                      # authentication: OK, we know it's Alice
def get_order(order_id):
    order = db.query(Order).filter(Order.id == order_id).first()
    return jsonify(order.to_dict())  # <- returns ANY order by id

The @login_required guarantees there’s a valid session. But authentication is not authorization. The code fetches Order.id == 1042 and returns it, period. At no point does it compare the order’s owner_id with the session’s identity. So Alice simply does:

GET /api/orders/1043 HTTP/1.1
Host: store.example.com
Cookie: session=<alices-valid-token>

And receives Bob’s order — shipping address, items, total, maybe the last 4 digits of the card. Multiply that by a for loop iterating from 1 to 99999 and you have a mass leak. That’s the read IDOR.

The write version is worse. If the same pattern exists in a PUT/PATCH/DELETE, the attacker doesn’t just read — they modify or delete other people’s records:

PATCH /api/orders/1043 HTTP/1.1
Host: store.example.com
Cookie: session=<alices-valid-token>
Content-Type: application/json

{"shipping_address": "42 Attacker Street"}

The root cause is always the same: the authorization decision was omitted or placed in the wrong spot. The server trusted that, if the client knows the id, it’s entitled to the object. The obscurity of the identifier was treated as a security control — and obscurity is not a control.

Variations and bypasses

IDOR rarely shows up in textbook form. It’s worth knowing the real-world variations we encounter in the field.

Predictable IDs (sequential)

The classic case: auto-incrementing identifiers (1, 2, 3…). They make enumeration trivial — just iterate. If you see small, increasing integers in URLs or bodies, that’s the first place to look.

UUIDs are not protection (leakage + enumeration)

Swapping id=1042 for a UUID v4 (f47ac10b-58cc-...) makes guessing harder but does not fix the IDOR. If the application doesn’t check ownership, any valid UUID the attacker obtains by another route works. And UUIDs leak all the time: in listing responses, logs, Location headers, emails, parameters on other screens, browser history. It’s also worth noting that UUID v1 embeds a timestamp + the node’s MAC address, which makes it partially predictable — so not every UUID format offers the same resistance to guessing (use v4, random). Either way, the UUID changes the cost of the attack, not its existence.

Mass assignment (the bridge to vertical escalation)

The application creates/updates an object by automatically binding the received JSON to the model’s columns. The attacker adds a field they shouldn’t control:

PATCH /api/users/me HTTP/1.1
Content-Type: application/json

{"name": "Alice", "role": "admin", "account_id": 7}

If the server does user.update(**request.json) with no field allowlist, role and account_id get written. A horizontal IDOR (writing to your own object) turns into vertical escalation (becoming admin) or a tenant jump.

Multi-tenant context

In B2B SaaS, the object belongs to an organization, not just a user. The check must include object.tenant_id == session.tenant_id (in addition to user ownership, where applicable). Failing here means one customer reading another customer’s data — an isolation break that’s usually treated as a critical incident, contractual implications included.

GraphQL: node IDs and the node(id:) pattern

GraphQL (Relay-style) concentrates object access by global ID — typically the base64 of something like Order:1043. The node(id: ...) resolver fetches by any type/ID. If authorization isn’t inside each object resolver, the IDOR spreads across the entire graph at once. Decoding the global ID (base64 -d) often reveals the sequential integer underneath — meaning the “opacity” of the global ID is just encoding, not authorization.

IDOR via alternate HTTP method

The read (GET /api/orders/1043) is protected, but the DELETE or PUT on the same resource isn’t. Developers frequently apply the check on one verb and forget the others. Always test every method against the same object.

IDOR via hidden parameter

The id isn’t in the URL — it’s in a secondary field, a header (X-Account-Id), an unsigned cookie, or a nested JSON parameter the UI never exposes. Tools like Param Miner (a Burp extension) help discover undocumented parameters and headers that control the object reference.

How we exploit it in a pentest

The methodology is straightforward and almost always produces results. The secret is two accounts.

1. Provision two accounts at the same level — call them victim and attacker. Ideally, also create a third at a higher level (admin) to map what each role can access.

2. Map the identifiers. Browsing as victim, catalog every object reference: IDs in URLs, bodies, headers, JSON responses. Note the IDs of victim’s resources (orders, invoices, messages, files).

3. Swap the IDs between accounts. Authenticated as attacker, replay victim’s requests using the attacker’s session but the victim’s IDs. In Burp Suite, that’s “Send to Repeater,” swap the session cookie and the id, and observe:

  • Got a 200 with the victim’s data? Read IDOR confirmed.
  • Did the PATCH/DELETE return success? Write IDOR — validate by re-reading as the victim.
  • Got a consistent 403/404? Probably protected (but test other verbs and parameters before ruling it out).

4. Automate with Burp Autorize. This extension is built precisely for this. You configure the cookies/headers of the low-privilege account; as you browse with the high-privilege account, Autorize resends each request with the downgraded credentials for the weak account and classifies it:

  • Bypassed! — the request worked without the proper privilege (a finding).
  • Enforced! — it was correctly blocked.
  • Is enforced??? — ambiguous; the detector couldn’t decide and the response requires manual analysis/configuration (you add a fingerprint string/regex so Autorize learns the blocking pattern).

This turns the BAC hunt into an almost passive scan while you use the application normally. Even so, manually confirm the findings flagged as Bypassed! — false positives happen when the error and success responses have a similar size/shape.

5. Enumerate at scale with Turbo Intruder or Burp’s Intruder: iterate the id over a range and measure the responses (status and body size). Spikes of 200s with non-trivial sizes give away accessible objects. For GraphQL, automate iterating the integer embedded in the global ID.

6. Demonstrate impact, not just the flaw. A good IDOR finding doesn’t say “I swapped the ID.” It shows how many records are accessible, what sensitive data leaks, and whether there’s write access (modification/deletion). That’s the difference between “Medium” and “Critical” in the report.

Use only the data from your test accounts to prove cross-reading. Don’t walk the entire base of real users — collect the minimum necessary (two or three neighboring IDs) to demonstrate the pattern, and respect the agreed-upon scope.

Summary for the report

  • Impact: An authenticated user accesses, modifies, or deletes data belonging to other users/organizations by swapping an object identifier in the request (horizontal escalation). When combined with mass assignment or unprotected administrative endpoints, it leads to vertical escalation (administrative account compromise) and mass leakage of personal data.
  • Severity: High to Critical. CVSS v3.1 of 6.5 for reads (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N), rising to 8.1 when there’s writing/deletion of other people’s data (...C:H/I:H/A:N) or tenant crossover. Vertical escalation to admin reaches Critical (9.x). Always score according to the specific case.
  • Preconditions: A valid low-privilege account and knowledge (or enumerability) of other users’ object identifiers.
  • Suggested evidence: A request/response pair showing account A accessing account B’s object (with A’s cookies/session highlighted and B’s id); the IDs involved; a capture of the sensitive data returned; for writes, proof of re-reading that confirms the change; Burp Autorize output flagging Bypassed!.

How to mitigate

Fixing BAC isn’t a one-off patch — it’s an architectural posture: authorization that is centralized, denied by default, and tied to the object.

1. Query with ownership scope (the most robust fix)

The best defense against IDOR is never being able to fetch an object that isn’t yours. Instead of “fetch by id and then check,” filter by the session’s identity in the query itself:

# WRONG: fetch by id, trust that the id implies entitlement
order = db.query(Order).filter(Order.id == order_id).first()

# RIGHT: the object only exists in the query if it belongs to the session's user
order = (db.query(Order)
           .filter(Order.id == order_id,
                   Order.owner_id == g.current_user.id)   # scope!
           .first())
if order is None:
    abort(404)   # 404 also for "unauthorized" — don't confirm the object's existence

With WHERE owner_id = :session_user, someone else’s id simply returns nothing. Authorization becomes a consequence of the query, not a separate step that can be forgotten. In multi-tenant, also add tenant_id == g.current_user.tenant_id.

2. Centralized authorization, deny-by-default

Don’t scatter if user.is_admin throughout the code. Centralize the decision in a layer/policy that every endpoint passes through, and make the default be to deny:

def authorize(user, action, resource):
    rule = POLICIES.get((action, type(resource).__name__))
    if rule is None:
        raise Forbidden()          # deny-by-default: no rule = denied
    if not rule(user, resource):
        raise Forbidden()

@app.patch("/api/orders/<int:order_id>")
@login_required
def update_order(order_id):
    order = get_owned_or_404(Order, order_id, g.current_user)  # scope (step 1)
    authorize(g.current_user, "order:update", order)           # policy (step 2)
    ...

Defense in depth: the scoped query already blocks the horizontal case; the explicit policy covers fine-grained rules and the vertical case.

3. RBAC/ABAC for functions (against BFLA)

For vertical escalation, protect functions by role. Apply the role check on the server, at the routing layer, before reaching the handler — never rely on the UI hiding the button:

@app.post("/api/admin/users")
@require_role("admin")     # RBAC: BFLA blocked at the door
def create_user(): ...

For context-dependent rules (time of day, department, resource status), use ABAC — attributes of the user and the object in the decision.

4. Field allowlist against mass assignment

Never blindly bind the body to the model. Accept only the fields the user is allowed to control:

ALLOWED = {"name", "email", "phone"}          # 'role' and 'tenant_id' excluded
data = {k: v for k, v in request.json.items() if k in ALLOWED}
user.update(**data)

5. Indirect references when it makes sense

Instead of exposing the database’s global id, expose an indirect per-session map (e.g., an index 0,1,2... that the server translates into the real IDs of that user). It’s an extra layer, not a substitute for the ownership check — but it reduces the enumeration surface. Never treat a UUID/hash as “authorization by obscurity.”

6. Automated authorization tests

Turn the pentest methodology into a regression test: for every sensitive endpoint, a test that verifies account A receives a 404/403 when accessing account B’s object, across every verb. Run it in CI. BAC is the category that benefits the most from automated negative tests, because the scanner doesn’t catch it.

Mitigation checklist

  • Every object query is scoped by the session’s identity (WHERE owner_id/tenant_id = :session).
  • Authorization centralized in a single layer, with a deny-by-default policy.
  • Per-object ownership check on every verb (GET, PUT, PATCH, DELETE) — not just on reads.
  • RBAC/ABAC enforced on the server for administrative functions (against BFLA), before the handler.
  • Field allowlist on create/update (against mass assignment); role/tenant_id/is_admin never come from the client.
  • GraphQL resolvers authorize per object individually, including node(id:).
  • Consistent response (404) for a nonexistent or unauthorized object — don’t leak existence via 403 vs 404.
  • Unpredictable identifiers (random UUID v4) as an extra layer — never as the sole control.
  • Automated authorization tests (account A vs account B’s object) running in CI.

IDOR and Broken Access Control are, at bottom, a question the server forgot to ask: “is this object really yours?”. Authentication confirms who’s knocking at the door; authorization decides which doors open. When the code fetches by the id the client handed over and returns it without checking ownership, it has delegated security to the user’s good faith — and good faith is not a threat model. Scope every query to the session, deny by default, test cross-access, and the IDOR stops being a number swap and becomes a silent 404.