Object-Level Authorization
A user can be perfectly authenticated, hold exactly the right role, and still have no business touching project 123.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Who checks that this authenticated user may act on this particular object, and where does that check have to happen?
GET /projects/123 should return project 123 to people who are members of it, and nothing at all to everyone else. Nobody wrote that requirement down, because it seemed too obvious to state.
The route is behind authentication and the caller has the project:read permission, so load the project by id and return it. The id came from the URL, which came from a link we rendered, which we only render for projects the user can see.
The id in the URL is a claim, not a permission. Changing 123 to 124 is not an exploit technique, it is typing.
- The id in the URL is a claim, not a permission. Changing
123to124is not an exploit technique, it is typing. - Sequential integer ids make the whole object space enumerable: a script walks 1 to 100000 and captures every project in the system in an afternoon.
- The list endpoint is scoped correctly — it only returns the caller's projects — which creates a false sense that the detail endpoint is safe. Scoping a list and scoping a fetch are separate pieces of code.
- The check exists on
GETand not onPATCH, or on both and not onDELETE, or on all three and not on the/projects/:id/exportendpoint added later. - Nested resources multiply the problem:
/projects/123/tasks/456needs task 456 to belong to project 123 *and* project 123 to belong to the caller. Checking only the outer object lets a caller read any task by pairing it with a project they own.
What is actually happening
- This is the check that turns "authenticated with a role" into "allowed here". Formally it is the resource-dependent part of the decision: the rule needs the row, so it cannot run before the row is loaded (Authorization in Backends).
- The classic failure has a name — insecure direct object reference — and one shape: an identifier supplied by the caller is used to fetch or mutate an object without verifying the caller's relationship to it.
- There are two ways to make the check reliable, and they are not equivalent. Filter in the query:
WHERE id = $1 AND workspace_id = $2— the row never enters the process if it is not the caller's. Load then check: fetch by id, then compare ownership in code — correct, but the object exists in memory, and every future code path that loads it must remember to check again. - Where the relationship is not a column — membership through a join table, sharing, hierarchies — the scoped query becomes a join or an
EXISTSsubquery. It is still the same technique: the predicate lives in the read. - Random, unguessable identifiers (UUIDv4, ULIDs, opaque tokens) reduce enumeration but are not authorization. They make discovery harder; they do not make access denied. Ids leak through referrers, logs, screenshots, shared links and support tickets (The Trust Boundary).
- For writes, the check and the write should be one statement where possible:
UPDATE projects SET ... WHERE id = $1 AND owner_id = $2returning zero rows *is* the denial, with no window between checking and acting.
The id is a claim
The entire lesson compresses into one sentence: GET /projects/123 does not mean "give me project 123", it means "I assert I am allowed to see project 123". Every subsequent design decision follows from taking that seriously.
The two remedies below are both correct and one is stronger. Loading then checking works, and leaves an unauthorized object sitting in process memory where a later refactor, a log statement or a serializer can leak it. Scoping the query means the row is never yours to leak.
const project = await db.projects.findById(id) if (!project) return res.status(404).end() if (!project.members.includes(user.id)) return res.status(403).end() return res.json(project)
const project = await db.one(
`SELECT p.* FROM projects p
JOIN project_members m ON m.project_id = p.id
WHERE p.id = $1 AND m.user_id = $2`,
[id, user.id],
)
if (!project) return res.status(404).end() // absent or not yours: same answer
return res.json(project)The first version has the unauthorized row in memory between the fetch and the check, distinguishes "exists but not yours" (403) from "does not exist" (404) and so confirms which ids are real, and requires project.members to have been loaded — an include someone may later drop for performance. The second returns nothing for either case, cannot leak a row it never fetched, and pushes the security predicate into the place the database enforces.
Every path, not every route
Object-level authorization is missed on the *second* way to reach an object, not the first. The endpoint everyone reviewed is scoped; the export, the batch fetch, the ?include= expansion and the nested child route are the ones that leak.
- 1Authenticate
Establish the principal from session or token
fails by Accepting a user id from the body and treating it as identity
- 2Coarse gate
Does this principal hold
task:updateat all?fails by Being the only check, so any agent may update any task
- 3Resolve parent, scoped
Load project 123 scoped by the principal's membership
fails by Loading by id alone, so any project id is accepted
- 4Resolve child, scoped by parent
Load task 456 with
project_id = 123in the predicatefails by Loading the task by id alone — the classic nested bypass
- 5Evaluate object rule
State, ownership, self-action rules on the loaded task
fails by Skipping because "the route already checked"
- 6Write with the predicate
UPDATE tasks SET ... WHERE id = $1 AND project_id = $2and assert one row changedfails by Writing by id and trusting the earlier read, leaving a check-then-act window
- 7Log the decision
Principal, action, resource, outcome, rule
fails by Logging only failures, so insider misuse leaves no trace
Steps 3 and 4 are the two that are routinely collapsed into one. Verifying only the parent is the most common nested-resource bypass, and it passes every route-level test you have.
Unguessable is not unauthorized
Switching to UUIDs is a good idea for several reasons and is not one of the defences discussed above. It changes the cost of *discovering* an id; it does not change what happens when someone has one. Ids escape constantly — through browser history, referrer headers, server logs, analytics payloads, shared links, support screenshots and third-party integrations.
The right framing is layers with different jobs: identifiers make bulk enumeration impractical, the scoped query makes access impossible, and the access log makes misuse detectable. Only the middle one is authorization.
| Mechanism | What it actually prevents | What it does not |
|---|---|---|
| Sequential integer id | Nothing | Enumeration is trivial: increment |
| UUID / ULID / opaque id | Bulk enumeration by guessing | Access by anyone who obtains one id |
| 403 vs 404 discipline | Confirming which ids exist | Access, if the check is absent |
| Rate limiting on 404s | Fast scanning; raises the cost | A slow, patient scan (Rate Limiting) |
| Load-then-check in code | Unauthorized responses | Leaks from logs, caches and serializers; check-then-act races |
| Scoped query / predicate in write | The row entering the process at all | Nothing at this layer — this is the control |
| Row-level security in the database | Application code that forgot | Rules richer than a row predicate (Where the Check Belongs) |
| Access logging | Nothing, by design | It is detection, not prevention (Audit Logs for Privileged Actions) |
How to build it
Most important first.
- Scope the read by the principal rather than checking after the read. Make the scoped accessor the only convenient one —
projects.findForMember(id, principal)— so the unscopedfindByIdis the odd path that draws attention in review. - Push the predicate into the write:
UPDATE ... WHERE id = ? AND tenant_id = ?, and treat a zero-row result as a 404. That eliminates the check-then-act race and the extra query at once. - Verify the whole path for nested resources. Fetch the child scoped by the parent *and* the parent scoped by the principal, or express both predicates in one query.
- Choose 404 over 403 for objects outside the caller's scope, so responses do not confirm that an id exists. Be consistent — a system that returns 403 sometimes and 404 other times is an oracle.
- Test it as a rule, not per endpoint: a shared test helper that, for every resource type, asserts that principal B receives 404 for principal A's object. Endpoint-by-endpoint tests are exactly what gets forgotten on the next endpoint.
- Use unguessable ids as a second layer, not the first. They are genuinely useful — they blunt enumeration and stop ids being meaningful in URLs — and they are not the control.
What can go wrong
- The check on the read path only.
GETis scoped,PATCHloads by id, and the write is the more damaging one. - Nested resources checked at one level:
/projects/mine/tasks/999returns someone else's task because only the project was verified. - A batch endpoint that accepts an array of ids and filters none of them, so one request exfiltrates a page of objects (Batch APIs and Partial Failure).
- A generic repository or admin CRUD layer added for internal use that exposes unscoped access to every model at once.
- Include/expand parameters (
?include=owner,comments) that traverse relationships without re-checking, returning objects the caller could not fetch directly. - An error message that leaks:
404for missing and403for "not yours" tells an enumerator which ids exist even when both refuse the data (Not Leaking Your Internals). - A cache keyed only by object id and not by principal, so the first authorized read populates a value the next caller receives without a check (Cache-Aside).
- Check-then-act: you verify ownership, then update. Between them the object can be transferred, deleted, or its state changed. Folding the predicate into the
UPDATEcloses the window entirely (Optimistic Concurrency). - A membership revoked while a request is in flight — the read was authorized when it ran, and the response is delivered after access ended. For most products this is acceptable; for regulated data it is the reason accesses are logged.
- If the check is missing on a read, an attacker with any account gets every object of that type in the system — every project, invoice, message, medical record — by iterating ids. This is the most commonly exploited web vulnerability class and requires no tooling beyond a browser.
- If the check is missing on a write, an attacker gets the ability to modify or delete other users' objects: reassign a project, change a shipping address, cancel someone's subscription, alter an invoice amount.
- If the check is missing on a nested route, an attacker gets the child objects of every parent by pairing a parent they legitimately own with arbitrary child ids — a bypass that route-level tests never catch.
- If unguessable ids are used *as* the control, an attacker who obtains one id — from a log, a shared link, a referrer header, a support screenshot — gets permanent access to that object, because nothing else is checked.
- If the response distinguishes "not found" from "not yours", an attacker gets a map of your object space even when the data itself is protected, which is enough to size a customer base or confirm that a specific person is a user.
- If a cache is keyed without the principal, an attacker gets whatever the previous caller was allowed to see — a cross-user data leak with no request-level vulnerability at all.
- "We use UUIDs, so ids cannot be guessed." Correct, and irrelevant. Authorization is a check, not an obstacle (Authorization in Backends).
- "The list endpoint is scoped, so the detail endpoint is fine." They are different queries. The scoping in one says nothing about the other.
- "The user only sees links to their own projects." The user sees whatever they request. Links describe your UI, not your API.
- "It is an internal admin endpoint." Internal endpoints are reachable by every authenticated employee, and increasingly by every service account and automation (Agent Authorization).
- "We check
req.user.id === project.ownerId." Fine for single-owner objects, and wrong the moment sharing, teams or organizations exist — which is usually the next quarter.
Operating it
- Count 404s and 403s per principal per resource type. A single principal generating hundreds of misses across sequential ids is enumeration, and it is visible in ordinary access logs (What a Backend Should Actually Log).
- Log the resource type and id with every authorization denial. Without the id you cannot distinguish a broken client from a scan.
- For high-value objects, log successful accesses too. Detecting misuse by a legitimately-authorized insider requires the allow log, not the deny log (Audit Logs for Privileged Actions).
- Add an assertion in tests rather than a metric in production for coverage: enumerate your routes and assert every one that takes an id has an ownership test.
- Query-scoped checks cost nothing extra at any scale *provided* the scoping column is in the index the query already uses. A
WHERE tenant_id = ?bolted onto a query indexed only onidturns a point lookup into a lookup plus a filter, which is fine, or into a scan, which is not (Should I Add an Index?). - Membership through a join table costs a join per read. At high read volume this is the case where a denormalized membership column or a materialized access list earns its complexity (Denormalization on Purpose).
- At 100x objects, enumeration attacks become more attractive and more visible: the same scan that was noise at small scale stands out clearly in per-principal error rates.
- Scoped queries are the strongest ordinary defence and the easiest to delete by accident — the security-critical clause is indistinguishable from a filter, and a future optimization can drop it without any test failing unless you wrote the test.
- Returning 404 for "not yours" protects existence and makes debugging genuinely harder for support, who cannot distinguish a deleted object from a permission problem without a separate internal tool.
- Checking at every level of a nested path costs additional reads on paths that are already several queries deep.
- Unguessable ids cost readability: support tickets, logs and URLs become opaque, and humans cannot say "project 42" any more.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALIndependent of stack, framework and scale. Any system where a caller names an object needs this check.
- DATABASE-SPECIFICThe "predicate in the write" technique depends on getting an affected-row count back: Postgres and MySQL both report it, and
UPDATE ... RETURNINGin Postgres lets you get the row and the authorization result in one round trip, where MySQL needs a separate read. On engines or ORMs that hide the row count, you must check it explicitly.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.