← mohdarif.com · All notes
Web Security · Authorization

Broken Access Control in a Document API: From IDOR to Enterprise Sharing

A user changes one number in a URL and reads someone else's document. This post explains why it happens, why two common fixes fall short, and what a complete fix looks like.

This post is for developers and security engineers who already know what a JWT is (the JWT post covers it) and want to understand why "the token is valid" does not mean "the request is allowed". You do not need any authorization background. By the end you should be able to spot this bug in code, explain it to a teammate, design a fix that works for a real multi-tenant product, and test it.

1. Two words to get straight first

Authentication answers "who are you?". A valid login or a valid JWT proves it.

Authorization answers "are you allowed to do this, to this thing?". Nothing about a login can answer that, because the answer depends on the thing.

Think of a hotel key card. It proves you are a guest (authentication). It does not tell the door to room 214 whether you may open it. The door has to check that separately, every time. Most access control bugs are doors that only check "is this a real key card?".

A few more terms we will use:

2. The bug

Here is a small document API:

@app.get("/api/v1/documents/{document_id}")
def get_document(document_id: int, jwt: str):
    user = validate_jwt(jwt)          # signature, expiry, issuer, audience: all correct
    if not user:
        raise Unauthorized()
    document = database.get_document(document_id)
    return document

The developer says: "We validate the signature, expiry, issuer and audience. The token handling is correct." That is true, and it is not the problem.

Alice is user 7 and Bob is user 9. Alice owns document 101 and Bob owns document 102. Alice is logged in and sends:

GET /api/v1/documents/102
Authorization: Bearer <Alice's valid token>

The API checks the token (valid), loads document 102, and returns Bob's document to Alice.

Alice (user 7)valid tokenasks for document 102Documents API1. Is the token valid? Yes2. Load document 1023. Return itDatabasedocument 102owner: Bob (user 9)Missing stepIs user 7 allowed to read document 102?
Figure 1. The token check passes, so the API never asks whether this user may read this document.

This bug has two common names:

They mean the same thing here. The attacker controls the document_id (in the path, the query string, the body or a header, it does not matter where) and the server never checks that this user may access that object.

Conditions for the attack: (1) the attacker has any valid account, (2) the ID is something they can change, (3) the server loads the object by ID without checking the requester's right to it. Condition 3 is the actual bug. The other two are almost always true.

3. First fix: check the owner

The simplest fix is to compare the owner with the user from the token:

document = database.get_document(document_id)
if document is None:
    raise NotFound()
if document.owner_id != user.id:
    raise Forbidden()
return document

For an app where every document has exactly one owner and nobody shares anything, this is a complete fix. Real products are rarely that simple, but it shows the shape: load the object, then ask whether this user may do this action on it.

Which error code?

People often return "unauthorized" for everything. The codes mean different things:

For a logged-in user asking for someone else's document, return 403, or 404 if you do not want to confirm the document exists.

"But our IDs are random UUIDs"

Random IDs make it harder to guess another document. They do not stop access. IDs leak through links, emails, logs and browser history, and a person who is given an ID once can keep using it. OWASP recommends unpredictable IDs and an authorization check. Use random IDs as a bonus, never as the check.

4. A fix that looks right but is not: check the tenant

Now the product grows. It serves many companies (tenants), and a manager proposes this fix:

document = database.get_document(document_id)
if document.tenant_id != current_user.tenant_id:
    raise Forbidden()
return document

"Users can no longer read other tenants' documents, so IDOR is fixed." Is it? Take one tenant, Tenant A, with three users:

UserRelationship to document 8271Should beTenant check says
AliceOwnerAllowAllow
BobDocument shared with himAllowAllow
CharlieSame company, no accessDenyAllow

Charlie works at the same company, so the tenant check lets him in. That is a real bug: an HR file or a board document is not meant for everyone in the company.

There is a second problem in the other direction. Suppose the customer shares one document with an outside contractor who belongs to Tenant B. A strict tenant check blocks that legitimate request, so the product breaks for a valid use case.

The tenant check is useful, but it answers a different question ("is this the right company?") from the one we need ("may this person do this to this document?").

5. What a complete rule looks like

A good authorization decision uses five inputs:

  1. Who is asking (the principal, taken from the verified token, never from the request).
  2. What action they want (read, edit, share, delete).
  3. Which resource, loaded from your own data.
  4. Which tenant the resource belongs to.
  5. What grants exist: ownership, direct shares, team shares, admin rights, guest access.
principal = validate_jwt(jwt)
document = database.get_document(document_id)
if document is None:
    raise NotFound()
if not authorization.can_access(principal, "document:read", document):
    raise Forbidden()          # or NotFound, if you want to hide existence
return document

The interesting part is what happens inside can_access. There are three common ways to model it, and real systems often mix them:

ModelIdeaExampleWhere it falls short alone
RBAC (role-based)Permissions come from a role"Editors can edit documents"A role says what you can do, not which documents
ABAC (attribute-based)Rules compare attributes of the user and the resource"Same tenant and same project and not marked confidential"Awkward for "share this one file with Bob"
ReBAC (relationship-based)Access follows relationships"Owner of the document", "member of a team that was given access"Needs the relationships to be stored and kept correct

For a document platform I would combine them: the tenant boundary (attribute), a role for what actions are possible (RBAC), and explicit grants on each document (relationship).

How grants might look

Document 8271, owner tenant: Tenant A

Grants:
  Alice               owner
  Team "Finance"      reader
  Bob                 reader
  Guest from Tenant B reader, expires 30 November

Then the rule for each user is simple to say out loud: Alice owns it, Bob and the Finance team have been given read access, and the guest has read access until the end of November. Charlie is on none of those lines, so he is denied. Two rules matter here:

6. Where to enforce it

There are four places people try. Each one is good at something and not enough alone:

PlaceGood atNot enough because
API gatewayIs the token valid? Is this route allowed for this kind of caller? Rate limits.It does not know that document 102 belongs to Bob.
Application serviceKnows the user, the action and the loaded object. The right place for the decision.Every endpoint and background job has to remember to call it.
DatabaseTenant filters in queries, or row-level security, as a safety net.Hard to express sharing rules and guest access there.
Central policy serviceOne place to write and audit the rules.Useless unless every code path actually asks it.

The rule I use: the decision can be central, but the enforcement must happen at every place that returns or changes data. That includes list endpoints, search, exports, background jobs and any AI feature that reads documents on a user's behalf.

As a second layer, scope the query itself so an unauthorized row is never loaded by mistake:

SELECT * FROM documents WHERE id = :id AND tenant_id = :tenant_id

Guests are the exception, because their access comes from a grant rather than their own tenant. Give them their own explicit path instead of removing the filter for everyone.

7. How to test it

Create a small set of test users and test each pair, not just the happy path.

Automate these. OWASP's advice is to write authorization tests and not to deploy changes that fail them, because this class of bug comes back every time someone adds a new endpoint.

8. Things that sound safe but are not

Summary

References