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
/adminor/internal/export. - Tampering with identity data: changing a cookie, hidden field or unsigned token claim such as
role=admin(L25). - Path traversal:
?file=../../etc/passwdescaping 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,
Refererheaders, 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-1002belongs 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-2001is 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.
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_oneoverwrites anytenantIdin the document, so a client can't write into another tenant. Updates must not be allowed to$seta differenttenantIdeither; add that check.- Aggregations leak through joins. The leading
$matchscopes the first collection, but a$lookupor$unionWithstage 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-88213serves one tenant's object to another; uset-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.
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.