Defending Against IDOR
IDOR is a missing-authorization bug, not a reference bug: the app exposes a handle to a resource and then trusts the caller to only ask for handles that are theirs. The fix is to check, on the server, for every request, that the authenticated caller is allowed to touch the specific object named - and to derive “who the caller is” from the session, never from the request. Obscuring the reference helps a little; it is never the fix.
1. Enforce object-level authorization server-side, on every request
Section titled “1. Enforce object-level authorization server-side, on every request”The one control that removes the bug. On each request that names an object, the server must answer “is this caller permitted to perform this action on this specific object?” before acting:
- Check at the object level, not just “is the user logged in.” Authentication (who you are) is not authorization (what you may touch).
- Do it on every endpoint and every verb. A GET that checks and a PUT that doesn’t is still IDOR - see verb tampering and insecure APIs.
- Deny by default: the resource is inaccessible unless a rule explicitly grants this caller access.
- Authorize at the field level too, not just the object. Owning an object is not permission to change every field of it - if the record holds privilege-bearing fields (
role,is_admin,emailused for reset), an owner-write rule lets a user escalate their own role and come back as admin on the next login (this is the chaining finish). Restrict self-service writes to an explicit allowlist of editable fields and reserverole/privilege changes for an admin-only path.
// Object-level: owner or admin may touch the record...match /api/profile/{userId} { allow read: if request.auth != null && (request.auth.uid == userId || request.auth.token.role == 'admin'); // ...but a self-service write may only set non-privileged fields. // role / is_admin / verified-email changes require an admin path. allow write: if request.auth != null && ( request.auth.token.role == 'admin' || (request.auth.uid == userId && !request.resource.data.diff(resource.data).affectedKeys() .hasAny(['role', 'is_admin', 'email'])) );}2. Derive identity and role from the session, never the request
Section titled “2. Derive identity and role from the session, never the request”The decisive detail. Authorization must use the caller’s identity as the server knows it, not values the client sent:
- Resolve the user id / role from the server-side session or a verified token, then compare that to the object’s owner. Never trust a
uid,role,is_admin, oruuidtaken from the URL, body, cookie, or a hidden field - those are attacker-controlled (the chaining and insecure-API attacks are exactly the app trusting a request-supplied role or uid). - Centralise it. An RBAC/ABAC layer that every request funnels through is far safer than per-endpoint checks that drift and get forgotten.
3. Unpredictable references - defence in depth, not the fix
Section titled “3. Unpredictable references - defence in depth, not the fix”Replacing sequential ids with UUIDv4 or salted hashes raises the bar for mass enumeration, but it does not fix IDOR:
- An unguessable reference that still isn’t access-controlled falls to any attack that obtains a valid reference - a leaked id, a reference from another response, or replaying one user’s request in another user’s session.
- So treat random references as a second layer behind section 1, never instead of it. The authorization check is what makes the app safe; the opaque id only makes enumeration harder.
- If you use references: generate them server-side at object-creation time and store the mapping in the database. Never compute an object’s reference on the client, and never derive it from a predictable input (
md5(uid)is reversible/enumerable - see mass enumeration).
4. Keep the authorization model off the client
Section titled “4. Keep the authorization model off the client”- Don’t ship the full function catalogue to the browser and gate it by a client-side role flag - the server must re-check, because the endpoint is reachable regardless of what the SPA chooses to render (the JS-disclosure attack in identifying is this mistake).
- Log and alert on authorization failures and on sequential/bulk access to object references - a spike is the signature of mass enumeration.
For the report
Section titled “For the report”- Primary: add a server-side object-level authorization check to the affected endpoint(s), for every verb, comparing the object’s owner/ACL against the identity resolved from the session.
- Field-level writes: restrict self-service create/update to an allowlist of non-privileged fields; route
role/is_admin/verified-email changes through an admin-only path, so owning a record can’t become escalating it. - Systemic: centralise this in an access-control layer (RBAC/ABAC) so new endpoints inherit it; deny by default.
- Never trust the request for identity or role - resolve both server-side.
- Defence in depth: unpredictable, server-generated references (UUIDv4); authorization-failure logging.
Avoid presenting “switch to UUIDs” as the fix - it’s the second step, and an app that does only that is still vulnerable to every reference-obtaining attack in this cluster.
Related
Section titled “Related”- Identifying - the client-side-role and reference-swap mistakes section 2 and 4 close
- Insecure APIs - per-verb authorization gaps
- Verb tampering - why “every verb” matters