---
title: 'Object-Level Authorization'
source: 'https://academia.sh/en/courses/authentication-and-authorization/object-level-authorization'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:32+00:00'
license: 'CC BY-SA 4.0'
---

# Object-Level Authorization

The ownership boundary that remains after the role, scope, and tenant checks, the difference between putting the check inside the query and leaving it outside, entity information leaking from the response code, and making the ownership criterion scannable.

The tenant boundary determined which set a request can touch. Within that set,
individual objects still exist. Two members of the same library carry the same role,
use a token with the same scope, and belong to the same tenant; even so, one must not
see the other's loan record.

This final boundary is tested the moment an identifier in the address bar is changed.
The role check passes, the scope check passes, the tenant check passes — and the
request must still be denied, because the requested **object** does not belong to the
one making the request.

## Three Implementations, the Same Endpoint

The ownership check can be done in three places: never, after the record is fetched, or
inside the query itself. The computation below runs all three against the same set of
requests.

```bash
cat > object.mjs <<'EOF'
// object.mjs — object-level authorization: is ownership inside the query or outside it
import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE loan (loan_id TEXT PRIMARY KEY, member_id TEXT NOT NULL, isbn TEXT NOT NULL,
                      status TEXT NOT NULL, note_text TEXT);
  INSERT INTO loan VALUES
    ('O-1','U-1001','978-0201896831','OPEN','Returned with a damaged cover'),
    ('O-2','U-1001','978-0262033848','CLOSED',NULL),
    ('O-3','U-1002','978-0131103627','OPEN','Second warning sent');
`);

// Three implementations. All three answer the same request: GET /loans/{id}
const endpoints = {
  unchecked: (ctx, id) => db.prepare("SELECT * FROM loan WHERE loan_id = ?").all(id),
  fetchThenCheck: (ctx, id) => {
    const row = db.prepare("SELECT * FROM loan WHERE loan_id = ?").all(id)[0];
    if (!row) return { status: 404, body: null };
    if (row.member_id !== ctx.member) return { status: 403, body: { error: "this record is not yours" } };
    return { status: 200, body: row };
  },
  checkInQuery: (ctx, id) => {
    const row = db.prepare("SELECT * FROM loan WHERE loan_id = ? AND member_id = ?").all(id, ctx.member)[0];
    return row ? { status: 200, body: row } : { status: 404, body: null };
  },
};

const ctx = { member: "U-1001" };          // the member making the request
const pad = (s, n) => String(s).padEnd(n);

console.log("implementation    requested  result                      is someone else's record shown");
for (const id of ["O-1", "O-3", "O-9"]) {
  const raw = endpoints.unchecked(ctx, id);
  const belongsToOther = raw[0] && raw[0].member_id !== ctx.member;
  console.log(`${pad("unchecked", 17)} ${pad(id, 8)} ${pad(raw.length ? "200 record returned" : "empty", 27)} ${belongsToOther ? "YES" : "no"}`);
}
for (const [name, f] of [["fetch-then-check", endpoints.fetchThenCheck], ["check-in-query", endpoints.checkInQuery]]) {
  for (const id of ["O-1", "O-3", "O-9"]) {
    const y = f(ctx, id);
    console.log(`${pad(name, 17)} ${pad(id, 8)} ${pad(y.status + (y.body ? " record returned" : " no body"), 27)} no`);
  }
}

// Entity leakage: do the response codes of the two implementations give away someone else's record?
console.log("\ninformation that can be inferred from the response codes");
for (const [name, f] of [["fetch-then-check", endpoints.fetchThenCheck], ["check-in-query", endpoints.checkInQuery]]) {
  const existingOthers = f(ctx, "O-3").status;
  const nonexistent = f(ctx, "O-9").status;
  console.log(`  ${pad(name, 18)} someone else's record: ${existingOthers}   nonexistent record: ${nonexistent}` +
    (existingOthers !== nonexistent ? "   -> record's existence leaked" : "   -> the two are indistinguishable"));
}

// An unguessable identifier is not an authorization check.
const randomId = randomUUID();
console.log("\nunguessable identifier attempt");
console.log(`  generated identifier: ${randomId.length} characters, not guessable from a dictionary`);
console.log("  but if the identifier leaks (a record, a log, a shared link) the unchecked endpoint still returns the record.");
console.log(`  unchecked endpoint for O-3: ${endpoints.unchecked(ctx, "O-3").length} record`);

// Endpoint scan: which endpoints declare their ownership criterion?
const ENDPOINTS = [
  { path: "GET /loans/{id}",            criterion: "member_id = :subject" },
  { path: "DELETE /loans/{id}",         criterion: "member_id = :subject" },
  { path: "GET /loans/{id}/note",       criterion: null },
  { path: "GET /members/{id}/fines",    criterion: "member_id = :subject" },
  { path: "POST /loans/{id}/extend",    criterion: null },
  { path: "GET /reports/overdue",       criterion: "role = clerk" },
];
console.log("\nendpoint                        ownership criterion    finding");
let missing = 0;
for (const e of ENDPOINTS) {
  const flagged = e.criterion === null;
  if (flagged) missing++;
  console.log(`${pad(e.path, 30)} ${pad(e.criterion ?? "-", 21)} ${flagged ? "CRITERION NOT DECLARED" : "-"}`);
}
console.log(`undeclared endpoints: ${missing} / ${ENDPOINTS.length}`);
EOF
node object.mjs
```

```text
implementation    requested  result                      is someone else's record shown
unchecked         O-1      200 record returned         no
unchecked         O-3      200 record returned         YES
unchecked         O-9      empty                       no
fetch-then-check  O-1      200 record returned         no
fetch-then-check  O-3      403 record returned         no
fetch-then-check  O-9      404 no body                 no
check-in-query    O-1      200 record returned         no
check-in-query    O-3      404 no body                 no
check-in-query    O-9      404 no body                 no

information that can be inferred from the response codes
  fetch-then-check   someone else's record: 403   nonexistent record: 404   -> record's existence leaked
  check-in-query     someone else's record: 404   nonexistent record: 404   -> the two are indistinguishable

unguessable identifier attempt
  generated identifier: 36 characters, not guessable from a dictionary
  but if the identifier leaks (a record, a log, a shared link) the unchecked endpoint still returns the record.
  unchecked endpoint for O-3: 1 record

endpoint                        ownership criterion    finding
GET /loans/{id}                member_id = :subject  -
DELETE /loans/{id}             member_id = :subject  -
GET /loans/{id}/note           -                     CRITERION NOT DECLARED
GET /members/{id}/fines        member_id = :subject  -
POST /loans/{id}/extend        -                     CRITERION NOT DECLARED
GET /reports/overdue           role = clerk          -
undeclared endpoints: 2 / 6
```

## The Observable Consequence of the Unchecked Endpoint

In the first part's table, the unchecked implementation returns someone else's `O-3`
record as is. The request arrived with a valid token, the member was signed into their
own account — only the path parameter changed. What leaks is a loan record; inside it
sits another member's reading history and the record's note field.

This class of defect is silent, because the application produces no error at any
layer: the log shows a successful request, the monitoring dashboard shows no change in
error rate. But it is also easy to catch — if a written answer exists for the question
"what is this record's relationship to the requester" for every endpoint, the endpoints
with no answer can be listed.

## Where the Check Sits Changes the Response

The second part separates the two correct implementations. The version that checks
after the record is fetched returns `403` for someone else's record and `404` for a
nonexistent one. Because the two differ, an observer can learn that an identifier they
never tried **exists**: `403` says "this record exists, but it is not yours."

In the version that checks inside the query, the two cases produce the same response.
The record is either in the requester's set or it does not exist; no distinction is
made between the two. This keeps entity information from leaking, and it has an extra
benefit: the check cannot be forgotten, because the criterion is part of the query. In
the fetch-then-check pattern, deleting the `if` line is enough; making the same mistake
in the check-in-query pattern requires rewriting the query itself.

The choice is not always toward `404`. In cases where the user **should** know they
lack access — a team member navigating to another team's dashboard, for instance —
`403` is more helpful, and the entity information is already shared information. The
criterion is this: is the record's existence information that should stay hidden from
the requester?

## An Unguessable Identifier Is Not an Authorization Check

The third part measures a common fallacy. Using an unguessable identifier instead of a
sequential number makes identifiers hard to **discover**; it does not **protect** the
record. Once an identifier leaks — through a record link, browser history, a server
log, or a shared link — the unchecked endpoint still returns the record; the output's
last line shows this.

An unguessable identifier's place is **defense in depth**, not a substitute for the
check. Identifier design blocks mass discovery; the authorization check blocks a
single access. The two do different jobs, and neither makes the other unnecessary.

## Making the Criterion Written

The last part scans endpoints: has an ownership criterion been declared for each one?
Two of the six endpoints declare no criterion, and both are sub-resources: a loan
record's note and its extension action. Sub-resources are where this defect appears
most often, because the check written for the parent resource is assumed to apply to
the sub-path too.

The criterion being written down does two things. When a new endpoint is added and the
criterion field is left empty, the scan finds it — something a code review missed shows
up in the list. Second, because the criterion is data, it can be tested: for every
endpoint, a test of "call it with a subject that is not the owner and expect an empty
result" can be generated automatically.

Every model built in this course — role, attribute, relationship, policy, scope,
tenant — makes its decision **independent of the object**. Object-level authorization is
that decision's last step, and when it is skipped, all the others are bypassed. This is
why authorization is not done in a single place; it is done layer by layer, along the
whole path of the request.

## Summary

- Object-level authorization is the final boundary left standing after the role, scope,
  and tenant checks all pass: the requested record's relationship to the requester.
- An unchecked endpoint returns someone else's record without producing an error; in the
  model, a single changed path parameter was enough.
- When the ownership criterion is put inside the query, forgetting it becomes harder and
  entity information does not leak; in the fetch-then-check pattern, the `403`/`404`
  distinction gives away the record's existence.
- An unguessable identifier makes discovery harder; it does not authorize access. Once
  the identifier leaks, the unchecked endpoint still returns the record.
- Writing down the ownership criterion for every endpoint makes it scannable and
  testable; in the model, both undeclared endpoints were sub-resources.

## Course Wrap-Up

This course began by separating two questions: **who you are** and **what you can do**.
The Authentication Flows topic took up the first question; it showed, from the basic
scheme through session- and token-based flows, the detail of signature verification, the
rotating use of refresh tokens, delegation protocols, and multi-factor authentication,
what problem each flow solves and where it breaks. The Credential Security topic tied
credential storage to measurable rules: slow-hash parameters, the separate jobs of salt
and pepper, the reset flow's surface, and the session's lifetime. The Authorization
Models topic built the second question across four models, narrowed a client's
authority with scope, carried the tenant boundary into the query, and, with this lesson,
came down to the object level.

Three criteria stayed constant through the course. A decision is **made** in one place
and **enforced** in another; separating the two makes auditing possible. The reason for
a denial must be distinguishable, but its detail must not leak to the requester. And
every check, when forgotten, must produce an observable consequence — a number of leaked
records, a number of opened endpoints, a distinguishable response code.

The next course, **The Data Access Layer and Business Logic**, descends into the layer
beneath these decisions. Moving the authorization check into the query surfaced in this
course as a security requirement; there, the same query becomes a question of
performance and design: how is data access separated from business logic, where is the
transaction boundary drawn, and how is the cost of different paths to the same data
measured?
