CyberNugget

L34 · Broken access control and IDOR

Module 5: Web Application Security · ~20 min · Lesson 34 of 71

🎯 What you'll learn

  • What "broken access control" covers, and why OWASP ranks it the number one web risk
  • How IDOR works: the "change 123 to 124" bug, and the less obvious places IDs hide
  • Why random IDs, hidden buttons and gateway checks don't fix it
  • The fix: scope every data access to the verified caller, deny by default, and test with two users

🔥 Why this matters

In May 2019, KrebsOnSecurity reported that First American Financial, a large US title insurer, had left about 885 million mortgage-related documents reachable on its website, going back to 2003. They included bank account numbers, Social Security numbers and wire transfer receipts. A customer who got a link to their own document could change one number in the URL and see a stranger's. The IDs were sequential and nothing checked who was asking. The SEC (2021) and New York's financial regulator (2023) both later fined the company.

1. Broken access control: doing more than you're allowed

Authentication (L21) answers "who are you?". Access control answers "may you do this, to this object?" (L27). Broken access control is any case where a user can act outside their intended permissions: read someone else's data, change it, or use a function their role shouldn't have.

OWASP's 2021 Top 10 moved it from fifth place to first: 94% of the applications in its data set were tested for some form of it, and it had more occurrences than any other category. The 2025 edition keeps it at number one, and folds SSRF (L35) into it. It's common because there's no single bug to grep for: every endpoint needs the right check, and frameworks can't know your business rules.

The failures fall along two directions:

          ┌───────────────────────────────────────┐
          │ ADMIN functions                       │
          │ DELETE /admin/users/{id}              │
          │ POST   /payments/{id}/approve         │
          └───────────────────────────────────────┘
                 ▲
                 │ VERTICAL: a customer calls an
                 │ admin or approver function
                 │ (privilege escalation; BFLA, L40)
          ┌──────┴────────────────────────────────┐
          │ CUSTOMER functions                    │
          │ GET /v1/accounts/{id}                 │
          │                                       │
          │  alice ──HORIZONTAL──▶ bob's ACC-1002 │
          │  (owns ACC-1001)  same role, someone  │
          │                   else's data         │
          │                   (IDOR / BOLA)       │
          └───────────────────────────────────────┘

Other forms OWASP lists in the same category:

  • Forced browsing: requesting pages the UI never links to, like /admin or /internal/export.
  • Tampering with identity data: changing a cookie, hidden field or unsigned token claim such as role=admin (L25).
  • Path traversal: ?file=../../etc/passwd escaping the intended folder.
  • Checks only in the client: the button is hidden, but the endpoint still works.
  • Missing checks on some methods: GET checks ownership, PUT and DELETE don't.

2. IDOR: the object belongs to someone else

Alice owns account ACC-1001. She opens her statement page, sees the ID in the URL, and types ACC-1002 instead:

 alice (owns ACC-1001, valid token)
   │ GET /v1/accounts/ACC-1002        ◀ she edited the URL
   ▼
 ┌──────────────────┐ token valid?            ✓
 │ Kong             │ consumer allowed route? ✓
 └────────┬─────────┘ (can't know who owns ACC-1002)
          │
          ▼
 ┌──────────────────┐ db.accounts.findOne(
 │ accounts-svc     │   { _id: "ACC-1002" })
 └────────┬─────────┘ ✗ no "is this alice's?" check
          │
          ▼
 200 OK {"owner":"bob","balance":48210.55}

This is an insecure direct object reference (IDOR): the app uses an identifier from the request to fetch an object without checking that the caller may access that object. The name comes from the OWASP Top 10 editions of 2007 to 2013, where it was its own category. In 2017 OWASP merged it into broken access control. API people call the same bug BOLA (broken object level authorization), the top item on the OWASP API Security Top 10 (L27, and in depth in L40).

Note what worked: authentication, the gateway, the role check. The only missing piece is one condition in one query.

Where object IDs hide

The URL path is the obvious place. Testers find IDOR in all of these:

Where Example
Path GET /v1/accounts/ACC-1002
Query string GET /v1/statements?accountId=ACC-1002
JSON body POST /v1/transfers {"fromAccount": "ACC-1002", ...}
Header X-Customer-Id: C-2002 set by the client
File name GET /files/stmt-2026-10-ACC-1002.pdf
Nested resource /customers/C-2001/accounts/ACC-1002 (checks the customer, not that the account is theirs)
Bulk and export POST /v1/exports {"accountIds": [...]}, or an Airflow job keyed by a request ID

Writes are worse than reads. An IDOR on fromAccount moves someone else's money, and one on DELETE /beneficiaries/{id} removes someone else's payee.

3. Fixes that don't fix it

Teams often reach for these first. Each helps a little and none is an access check:

  • Random IDs (UUIDs). They stop guessing and bulk scraping (sequential IDs made First American trivially scrapable), but IDs leak: in shared links, logs, Referer headers, emails, support tickets and other API responses. A leaked ID still works. Use random IDs as defence in depth.
  • Hiding the button. The UI isn't a security boundary. Anyone can send the request with curl.
  • Checking at the gateway. A gateway such as Kong can verify the token and the route, but it doesn't know that ACC-1002 belongs to Bob (L27, L43). Object-level checks belong in the service that owns the data.
  • Encoding or encrypting the ID. Base64 is not a check. Encrypted IDs are still bearer references: anyone who sees one can replay it.
  • Taking the owner from the request. ?customerId=C-2001 is attacker-controlled too. The caller's identity must come from the verified token or session, never from a parameter.

That last one matters behind a gateway. Kong, for example, adds headers like X-Consumer-ID after it authenticates the caller, and the service may trust them only if the service is reachable solely through Kong and Kong overwrites any client-sent value (L09's "headers a gateway should strip").

4. The fix: scope every access to the caller

The rule is simple: every read or write of an object checks that this caller may use this object, on the server, by default. Three patterns do it.

1. Scope the query. Put the owner condition in the database query itself. If the object isn't the caller's, it simply isn't found:

 db.accounts.findOne({ _id: "ACC-1002",
                       ownerId: "C-2001" })   ◀ from the token
 → null → 404 Not Found

2. Load, then decide. For richer rules (an advisor's clients, a joint account, a delegate with read-only rights), load the object and ask one policy function or OPA (L27): can_view(user, account). Use this when ownership isn't a single field.

3. Indirect references. Give each user their own list ("account 1, 2, 3") mapped to real IDs on the server. This was OWASP's original advice; it's rare in APIs today but still useful in server-rendered apps.

Then the details that separate good from good enough:

  • 404 or 403? Returning 404 for "exists, but not yours" avoids confirming which IDs exist. GitHub does this for private repositories. Log the real reason internally.
  • Deny by default. A missing owner field or an unknown role must produce "no" (L04, L27).
  • Check writes and lists too. PUT, DELETE, list filters and bulk endpoints each need the check.
  • Detect enumeration. One caller getting dozens of 404s on neighbouring IDs is a signal. Alert on it.

🧪 Worked example: a statement download, vulnerable vs fixed

A Spring Boot service on EKS serves PDF statements from MongoDB. The token is checked by Kong and by Spring Security, and the code looks harmless:

// VULNERABLE: any logged-in customer gets any statement
@GetMapping("/v1/statements/{statementId}")
Statement get(@PathVariable String statementId) {
    return statements.findById(statementId)
        .orElseThrow(NotFoundException::new);
}

The same service's update endpoint took the owner from the body, which is no better:

// VULNERABLE: the caller says whose statement it is
@PatchMapping("/v1/statements/{id}")
void update(@PathVariable String id,
            @RequestBody UpdateRequest req) {
    var s = statements
        .findByIdAndCustomerId(id, req.customerId())
        .orElseThrow(NotFoundException::new);
    ...
}

Fixed: the customer ID comes from the verified JWT, and the query is scoped by it. Spring Data MongoDB turns findByIdAndCustomerId into { _id: ..., customerId: ... }:

// FIXED: identity from the token, query scoped to it
@GetMapping("/v1/statements/{statementId}")
Statement get(@PathVariable String statementId,
              @AuthenticationPrincipal Jwt jwt) {
    String customerId = jwt.getClaimAsString("customer_id");
    if (customerId == null) throw new AccessDeniedException(
        "no customer_id claim");               // deny by default
    return statements
        .findByIdAndCustomerId(statementId, customerId)
        .orElseThrow(NotFoundException::new);  // 404 either way
}

Add a log line for each miss, so enumeration shows up in your log search:

2026-11-07T10:42:13Z WARN statements-svc authz.miss
  sub=C-2001 statementId=STM-88213 route=GET /v1/statements/{id}
  corr=7c1e9a02-5f3b-4d7e-9a41-0b6c2d8e1f55

And a test that pins the rule, using two real users:

@Test
void bobCannotReadAlicesStatement() {
    var aliceStmt = fixtures.statementFor("alice");
    client.as("bob").get("/v1/statements/" + aliceStmt.id())
          .expectStatus().isNotFound();
}

(fixtures and client.as(...) are your own test helpers that mint a token per test user. The second deep dive builds this into a full matrix.)

🛠 Try it (optional, ~5 min)

Build an IDOR and fix it, on your own machine. Save as idor_demo.py:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

ACCOUNTS = {
    "ACC-1001": {"owner": "alice", "balance": 1520.10},
    "ACC-1002": {"owner": "bob", "balance": 48210.55},
}
FIXED = False  # flip to True, restart, run the curls again

class Api(BaseHTTPRequestHandler):
    def do_GET(self):
        user = self.headers.get("X-User")  # stand-in for a token
        acc = ACCOUNTS.get(self.path.rsplit("/", 1)[-1])
        if acc and FIXED and acc["owner"] != user:
            acc = None                     # not yours: not found
        body = json.dumps(acc or {"error": "not found"})
        self.send_response(200 if acc else 404)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body.encode() + b"\n")

HTTPServer(("127.0.0.1", 8034), Api).serve_forever()

Run python3 idor_demo.py in one terminal, then in another:

U=http://127.0.0.1:8034/v1/accounts
curl -s -H 'X-User: alice' $U/ACC-1001
curl -s -H 'X-User: alice' $U/ACC-1002    # Bob's account

With FIXED = False, both return a balance:

{"owner": "alice", "balance": 1520.1}
{"owner": "bob", "balance": 48210.55}

Set FIXED = True, restart, and the second call returns {"error": "not found"} with a 404. Now the uncomfortable part: run curl -s -H 'X-User: bob' $U/ACC-1002. It works, because the server trusts a header the client wrote. That's the "owner from the request" mistake from section 3. In a real service, the identity comes from a verified token, or from a header that only the gateway can set.

🧠 Key takeaways

  • Broken access control means acting outside your permissions; it's OWASP's number one risk in 2021 and 2025.
  • IDOR (BOLA in APIs): an ID from the request reaches the database without an "is this yours?" check.
  • IDs hide in paths, queries, bodies, headers, file names and bulk endpoints, and writes are the worst case.
  • Random IDs, hidden buttons and gateway checks help but don't fix it; the service must check.
  • Scope every query by the caller's identity from the verified token, return 404, and test with two users.

📖 Words to know

Term Meaning
Broken access control Any way a user can act outside their intended permissions (OWASP A01)
IDOR Insecure direct object reference: using a request-supplied ID without checking the caller may access that object
BOLA Broken object level authorization: the API Security Top 10's name for IDOR
Horizontal escalation Reaching another user's data at the same privilege level
Vertical escalation Using functions reserved for a higher privilege level, such as admin
Forced browsing Requesting unlinked pages or endpoints directly
Path traversal Using ../ sequences to escape the intended directory
Scoped query A database query that includes the caller's identity, so other users' objects can't match
Enumeration Walking through IDs (1001, 1002, ...) to discover and harvest objects

⏭ Next time

L35 covers server-side request forgery (SSRF): tricking your server into calling internal services, including the cloud metadata endpoint at 169.254.169.254.

Deep dive 1

Multi-tenant isolation: making the tenant filter impossible to forget

Many fintech platforms serve several client firms from one deployment: one set of pods on EKS, one MongoDB cluster, every document tagged with a tenantId. Then IDOR has a bigger sibling: a missing filter that leaks one company's data to another. One forgotten tenantId condition can expose every customer of another tenant at once.

The isolation spectrum

AWS's SaaS guidance names three models. Most teams mix them by data sensitivity:

 SILO                 BRIDGE                POOL
 a database (or       one cluster, a        one database, shared
 cluster) per         database or schema    collections; every
 tenant               per tenant            document has tenantId
 ─────────────────────────────────────────────────────────────
 strongest            medium                weakest isolation,
 isolation, highest                         cheapest, easiest to
 cost and ops                               run; the app MUST
                                            filter every query

Relational databases offer a safety net for the pool model: PostgreSQL row-level security makes the database itself add the tenant filter. MongoDB has no row-level security, so in a pooled Mongo design the application is the only thing standing between tenants. The silo or bridge model can lean on MongoDB's own access control instead: a separate database user per tenant, granted roles on just that tenant's database.

Make the safe path the only path

Code review won't reliably catch one missing filter among hundreds of queries. Instead, don't let service code touch the raw collection. Hand it a wrapper that adds the tenant on every call:

class TenantCollection:
    """The only way service code may reach a collection."""

    def __init__(self, coll, tenant_id):
        if not tenant_id:
            raise PermissionError("no tenant in context")
        self._c, self._t = coll, tenant_id

    def _scope(self, flt):
        return {"$and": [flt or {}, {"tenantId": self._t}]}

    def find_one(self, flt=None, **kw):
        return self._c.find_one(self._scope(flt), **kw)

    def find(self, flt=None, **kw):
        return self._c.find(self._scope(flt), **kw)

    def update_one(self, flt, update, **kw):
        return self._c.update_one(self._scope(flt), update, **kw)

    def insert_one(self, doc, **kw):
        return self._c.insert_one({**doc, "tenantId": self._t}, **kw)

    def aggregate(self, pipeline, **kw):
        first = {"$match": {"tenantId": self._t}}
        return self._c.aggregate([first, *pipeline], **kw)

Design notes, each one a real bug seen in the wild:

  • $and, not a dict merge. The caller's filter can't widen the tenant condition, whatever it contains.
  • The tenant comes from the token (a claim set by your identity provider), never from a header or body field the client controls.
  • insert_one overwrites any tenantId in the document, so a client can't write into another tenant. Updates must not be allowed to $set a different tenantId either; add that check.
  • Aggregations leak through joins. The leading $match scopes the first collection, but a $lookup or $unionWith stage reads another collection without the filter. Review those stages, or forbid them in the wrapper.
  • Caches and files need the tenant too. A cache key of statement:STM-88213 serves one tenant's object to another; use t-042:statement:STM-88213. The same goes for S3 prefixes, where an IAM condition on a tenant tag (L27's ABAC) adds a second layer.

🧪 Worked example: the Airflow job that crossed tenants

A nightly Airflow DAG builds statements for every tenant. It loops over tenants, but the task that fetches transactions was written before multi-tenancy:

# VULNERABLE: tenant passed in, but never used
def fetch_txns(tenant_id, account_id, **_):
    return list(db.txns.find({"accountId": account_id}))

Account IDs were only unique within a tenant, so ACC-1001 of tenant t-042 pulled in the transactions of ACC-1001 in tenant t-077. Two firms' customers got each other's lines on their statements. With the wrapper, the code can't be written without a tenant:

def fetch_txns(tenant_id, account_id, **_):
    txns = TenantCollection(db.txns, tenant_id)
    return list(txns.find({"accountId": account_id}))

Batch jobs deserve the same care as APIs: they run with broad service credentials, so a single missing filter has no user permission to catch it.

🧠 Extra takeaways

  • In a shared ("pool") database, one forgotten tenant filter leaks a whole company's data.
  • MongoDB has no row-level security; enforce tenancy in a wrapper all code must use, or isolate per database.
  • Take the tenant from the verified token, force it on inserts, and never let updates change it.
  • Watch $lookup, $unionWith, caches, file paths and batch jobs; they bypass a simple query filter.
Deep dive 2

Testing access control with two users (or three)

Access control bugs are invisible to normal tests. A test logged in as Alice who reads Alice's data passes whether or not the check exists. You only find the bug by asking for Alice's data as someone else. OWASP's Web Security Testing Guide calls this test WSTG-ATHZ-04, Testing for Insecure Direct Object References.

The cast

Create at least these fixture identities in your test environment:

Identity Why
alice (tenant A, customer) Owns the test objects
bob (tenant A, customer) Same role, same tenant: catches horizontal IDOR
carol (tenant B, customer) Other tenant: catches tenant leaks
ops (tenant A, support role) Higher role: checks vertical rules both ways
anonymous / expired token Checks that authentication is enforced at all

The matrix

For each endpoint and each object, write the expected answer for every identity. Then run every cell. The matrix is the specification; the test runner just checks it:

                      alice  bob   carol  ops   anon
 GET    /stmts/A1      200   404   404    200   401
 PATCH  /stmts/A1      204   404   404    403   401
 DELETE /payees/P1     204   404   404    403   401
 GET    /stmts?acct=A  200*  200∅  200∅   200*  401
                       * = only alice's   ∅ = empty list

The last row is easy to miss. A list endpoint rarely returns 404; the bug shows up as Bob's list containing Alice's items. Check the contents, not just the status.

🧪 Worked example: the matrix as a parametrized test

Run against your local or dev environment, with test users only:

import pytest, requests

BASE = "http://localhost:8080"
CASES = [
    # (method, path, user, expected status)
    ("GET",    "/v1/statements/STM-A1", "alice", 200),
    ("GET",    "/v1/statements/STM-A1", "bob",   404),
    ("GET",    "/v1/statements/STM-A1", "carol", 404),
    ("PATCH",  "/v1/statements/STM-A1", "ops",   403),
    ("DELETE", "/v1/payees/PE-P1",      "bob",   404),
    ("GET",    "/v1/statements/STM-A1", None,    401),
]

@pytest.mark.parametrize("method,path,user,status", CASES)
def test_access_matrix(method, path, user, status, tokens):
    headers = {}
    if user:
        headers["Authorization"] = f"Bearer {tokens[user]}"
    body = {} if method in ("PATCH", "POST") else None
    r = requests.request(method, BASE + path, headers=headers,
                         json=body, timeout=5)
    assert r.status_code == status, f"{user} {method} {path}"

def test_bob_list_has_no_alice_items(tokens):
    h = {"Authorization": f"Bearer {tokens['bob']}"}
    r = requests.get(BASE + "/v1/statements", headers=h, timeout=5)
    owners = {s["customerId"] for s in r.json()["items"]}
    assert owners <= {"C-BOB"}

tokens is a fixture that logs each test user in against your dev identity provider. Run it in CI on every merge, and add a row whenever someone adds an endpoint. If your OpenAPI spec lists every route, a test can fail when a route has no row in the matrix, which turns "we forgot to test the new endpoint" into a red build.

Tools that do the replay for you

When testing manually in your own test environment, proxy tools automate the swap. Burp Suite's Autorize extension (and similar plugins for OWASP ZAP) replay each request you make as Alice with Bob's session, and flag responses that come back the same. It's a fast way to sweep an app before writing the matrix, but only point it at systems you're authorized to test.

🧠 Extra takeaways

  • A test that reads your own data proves nothing about access control; always test as a second user.
  • Use at least: owner, same-role peer, other tenant, higher role, and no token.
  • Write the expected result for every endpoint and identity as a matrix, and check list contents too.
  • Make a missing matrix row fail the build, so new endpoints can't skip the test.

The quiz for this lesson and the Tests section need JavaScript.