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.
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:
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.
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.
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.
People often return "unauthorized" for everything. The codes mean different things:
401 Unauthorized: the request has no valid credentials. (The name is misleading. It really means "not authenticated".)403 Forbidden: you are logged in, but you are not allowed.404 Not Found: sometimes chosen on purpose so an outsider cannot tell whether a document exists. HTTP allows this for resources you want to hide.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.
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.
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:
| User | Relationship to document 8271 | Should be | Tenant check says |
|---|---|---|---|
| Alice | Owner | Allow | Allow |
| Bob | Document shared with him | Allow | Allow |
| Charlie | Same company, no access | Deny | Allow |
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?").
A good authorization decision uses five inputs:
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:
| Model | Idea | Example | Where 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).
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:
There are four places people try. Each one is good at something and not enough alone:
| Place | Good at | Not enough because |
|---|---|---|
| API gateway | Is 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 service | Knows 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. |
| Database | Tenant filters in queries, or row-level security, as a safety net. | Hard to express sharing rules and guest access there. |
| Central policy service | One 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.
Create a small set of test users and test each pair, not just the happy path.
PUT, DELETE, list and search endpoints, and on IDs sent in the body or headers.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.