ASVS 5.0 V8.2.2 (Level 1) requires that access to specific data items is restricted to consumers with explicit permission to those items. This is the control that prevents insecure direct object reference (IDOR) and broken object level authorization (BOLA). Being logged in, or having the right role, is not enough to read or change a particular record.
Why it matters
Look at any API and you will find paths like /invoices/1042 or a request body with an account id. If the server only asks "is this a valid user?" and then loads whatever id was supplied, anyone can walk the ids. Changing 1042 to 1043 hands over another customer's data.
This flaw is common, easy to exploit and easy to automate, and it often leaks at scale because attackers simply increment a number. Unpredictable ids slow guessing but do not fix it. The check has to happen on every access.
How to implement it
The rule is simple: every data access combines the object id with the caller's authorization, evaluated on the server.
Scope the query
The safest pattern builds the permission into the lookup, so a record you may not see behaves like a record that does not exist.
def get_invoice(db, user, invoice_id):
row = db.execute(
"SELECT * FROM invoices WHERE id = ? AND owner_id = ?",
(invoice_id, user.id),
).fetchone()
if row is None:
raise NotFound() # same response for missing and forbidden
return row
Use a central policy for richer rules
When access depends on teams, sharing or state, call one function from every handler:
async function loadInvoice(req, res, next) {
const invoice = await db.invoices.findById(req.params.id);
if (!invoice || !(await policy.can(req.user, "read", invoice))) {
return res.sendStatus(404);
}
req.invoice = invoice;
next();
}
Cover every entry point
- Reads, updates and deletes, not only reads.
- Nested resources: /teams/5/members/9 must check the team and the member.
- Bulk and list endpoints: filter results, do not just check the first item.
- Ids in bodies, headers and query strings, not only the path.
- GraphQL resolvers and batch endpoints, where one request can name many ids.
- File downloads and signed URLs, which should be tied to the requester.
- Background jobs and exports, which run later with stale assumptions.
Do not trust client supplied ownership
Never take owner_id, account_id or tenant_id from the request body when the server knows it from the session. Ignore those fields on input.
Add defense in depth
Use random identifiers for public ids to reduce enumeration, add tenant scoping at the data layer (such as row level security) so a forgotten check fails closed, and log denied access attempts to spot enumeration.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY owner_only ON invoices
USING (owner_id = current_setting('app.user_id')::bigint);
How to test it
Two accounts are all you need.
- Create user A and user B, each with their own data.
- As A, record the ids of A's objects.
- As B, request each of A's ids on every endpoint that accepts an id: GET, PUT, PATCH and DELETE.
- Expect 403 or 404 every time, and verify the data was not changed.
- Repeat with ids in the body, with list filters and with nested paths.
- Try a low privilege role against a high privilege user's data inside the same tenant.
Automate this as a pair of tests per endpoint. API scanners that replay requests under another identity find many of these quickly, and the API scan demo shows what that looks like:
Click through it at your own pace, or open it full screen.
Common mistakes
- Checking authentication only and trusting the id in the URL.
- Hiding the id in an encoded or hashed form and assuming that is access control.
- Checking the parent object but not the child.
- Returning 403 for forbidden and 404 for missing, which confirms which ids exist.
- Securing GET but forgetting PUT, PATCH and DELETE.
- Filtering a list in the user interface while the API returns every record.
- Letting the client set tenant or owner fields on create and update.
Try it yourself
Pick the three endpoints that return personal or financial data. Write the two account test above for each, run it, and fix whatever fails by moving the permission into the query. Add the tests to your pipeline so new endpoints get the same treatment.
Quick check
Do UUIDs prevent IDOR?
They make guessing harder, but ids leak through logs, emails and shared links. The server must still check access to each object.
Should forbidden objects return 403 or 404?
Either meets the requirement. Returning 404 avoids confirming that the object exists, which is better against enumeration.
How is this different from V8.2.1?
V8.2.1 asks whether you may call the function at all. V8.2.2 asks whether you may touch this particular record with it.