AuthzGENERALDATABASE-SPECIFIC

Object-Level Authorization

A user can be perfectly authenticated, hold exactly the right role, and still have no business touching project 123.

What actually happensHow to build it

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.

The question

Who checks that this authenticated user may act on this particular object, and where does that check have to happen?

The requirement

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 obvious build

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.

Why it breaks

The id in the URL is a claim, not a permission. Changing 123 to 124 is not an exploit technique, it is typing.

How it breaks in production
  • The id in the URL is a claim, not a permission. Changing 123 to 124 is 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 GET and not on PATCH, or on both and not on DELETE, or on all three and not on the /projects/:id/export endpoint added later.
  • Nested resources multiply the problem: /projects/123/tasks/456 needs 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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 EXISTS subquery. 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 = $2 returning 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.

Two ways to enforce, one that is harder to undo
Load, then check
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)
Scope the query
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.

Where the check has to appear on a nested write
  1. 1
    Authenticate

    Establish the principal from session or token

    fails by Accepting a user id from the body and treating it as identity

  2. 2
    Coarse gate

    Does this principal hold task:update at all?

    fails by Being the only check, so any agent may update any task

  3. 3
    Resolve parent, scoped

    Load project 123 scoped by the principal's membership

    fails by Loading by id alone, so any project id is accepted

  4. 4
    Resolve child, scoped by parent

    Load task 456 with project_id = 123 in the predicate

    fails by Loading the task by id alone — the classic nested bypass

  5. 5
    Evaluate object rule

    State, ownership, self-action rules on the loaded task

    fails by Skipping because "the route already checked"

  6. 6
    Write with the predicate

    UPDATE tasks SET ... WHERE id = $1 AND project_id = $2 and assert one row changed

    fails by Writing by id and trusting the earlier read, leaving a check-then-act window

  7. 7
    Log 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.

MechanismWhat it actually preventsWhat it does not
Sequential integer idNothingEnumeration is trivial: increment
UUID / ULID / opaque idBulk enumeration by guessingAccess by anyone who obtains one id
403 vs 404 disciplineConfirming which ids existAccess, if the check is absent
Rate limiting on 404sFast scanning; raises the costA slow, patient scan (Rate Limiting)
Load-then-check in codeUnauthorized responsesLeaks from logs, caches and serializers; check-then-act races
Scoped query / predicate in writeThe row entering the process at allNothing at this layer — this is the control
Row-level security in the databaseApplication code that forgotRules richer than a row predicate (Where the Check Belongs)
Access loggingNothing, by designIt 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 unscoped findById is 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

Failure modes
  • The check on the read path only. GET is scoped, PATCH loads by id, and the write is the more damaging one.
  • Nested resources checked at one level: /projects/mine/tasks/999 returns 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: 404 for missing and 403 for "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).
What can race
  • 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 UPDATE closes 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.
Security
  • 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.
Misreads
  • "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

How you see it in production
  • 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.
What changes at 10x and 100x
  • 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 on id turns 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.
What this costs
  • 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 ... RETURNING in 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.