Broken Access Control (IDOR / BOLA)
GET /invoices/100 works; GET /invoices/101 also works and it is not yours. Authentication succeeded, authorization was never asked — the most common serious vulnerability in web applications and APIs.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
Why it is so common
The bug is an absence, not a mistake. A handler loads a record by id and returns it; nothing in it is wrong, and nothing in it checks ownership. Tests pass because tests use the owner's account. Scanners cannot see it because the response is a valid 200. Frameworks cannot prevent it because they do not know which user owns invoice 101.
It appears wherever a client-supplied identifier selects a record: path parameters, query strings, JSON bodies, file keys, websocket messages, GraphQL arguments, and the second page of a paginated list.
1app.get('/invoices/:id', requireLogin, async (req, res) => {2 res.json(await invoices.byId(req.params.id))3})1app.get('/invoices/:id', requireLogin, async (req, res) => {2 // The repository method REQUIRES a principal and scopes the query:3 // SELECT ... WHERE id = $1 AND tenant_id = $24 const inv = await invoices.forPrincipal(req.principal).byId(req.params.id)5 if (!inv) return res.sendStatus(404) // absent and forbidden look identical6 res.json(inv)7})The fix that survives is structural: a data-access API that cannot be called without a principal, so the tenant/owner predicate is part of the query rather than an optional check after it.
Variants worth knowing
Write-side: PUT /invoices/101 with a check on read but not on update. Indirect: the id of a parent is checked but the child (/invoices/100/lines/5019) belongs to another invoice. Function-level: /admin/export guarded only by not being linked. Identifier in the body: {"userId": 124} accepted rather than taken from the session. Batch and search: a filter parameter that lets a query cross tenants.
Unguessable ids (UUIDs) reduce casual enumeration and are worth using, but they are depth, not the control: ids leak through references, logs, exports and other users' pages.
Key points
- Authentication succeeded, authorization was never asked.
- The response is a valid 200, so scanners and tests miss it.
- Fix structurally: data access that requires a principal.
- Check every place a client supplies an id, including bodies, children and filters.
- The single most valuable security test: user A cannot read user B's record.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → own account, observe ids.
- 2Substitute an id in path, body, or filter.
- 3Iterate the id space; export.
- Entire record type exposed to any customer; often the whole customer base.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Principal-scoped repositories.
- • Take the user id from the session, never the request.
- • Per-resource A-cannot-read-B tests.
- • Opaque ids as depth.
- • One principal, many distinct ids, short window.
- • Requester ≠ owner without sharing relation.
- • Access logs → which records were read; fix all sibling endpoints; notify.
- • New endpoints; sharing rules; admin tools.