---
title: 'Broken Access Control'
source: 'https://academia.sh/en/courses/secure-coding/broken-access-control'
course: 'Secure Coding'
language: en
updated: '2026-09-26T03:02:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts the endpoints in the authorization matrix that have no object-level control'
    - 'Measures the number of wrongly-passed requests within the set of requests that pass the role check'
    - 'Compares the coverage of a central gate built on default-deny against its cost to legitimate work'
---

# Broken Access Control

Deriving, from the authorization matrix, the number of endpoints with no object-level control: five of fourteen endpoints perform only a role check, fourteen of the seventy-two requests that pass the role check reach someone else's object, a central gate built on default-deny brings this number down to zero while stopping ten legitimate requests, and a fifteenth endpoint added without declaring a criterion raises the distributed check's wrongly-passed count to seventeen while opening no new path at the central gate.

The previous two lessons secured the identity behind the request: who it is was established
without leaking, and the carrier of that identity was renewed at the right moments and deleted
at the right moments. Knowing the identity correctly is only half the decision. When a known
identity sends a request to an endpoint, a second question is asked, and it is asked per
endpoint.

The question has two separate forms, and mixing them up produces the flaw. The first is "can
this role perform this action?", the second is "can this principal touch **this object**?". An
endpoint that asks only the first opens up every object belonging to everyone in its role. This
is **broken object level authorization**: it is done with a valid session, the right role, and a
single path parameter changed. Trying this against a service **is both illegal and
unprofessional without written authorization and a defined scope**; the measurement below runs
on a matrix we wrote ourselves.

## Matrix, Principal and Object

The measurement's input is an authorization matrix: for each endpoint, which roles can call it
and how the requested object is tied to the request. The second field is written twice — the
criterion the endpoint **is supposed to carry** and the criterion the code **actually applies**
today. Writing the two separately is what lets the gap turn into a number.

The criterion is written in three forms. `owner` means the object directly belongs to the
requester; `assigned` means a work order has been given to that staff member; `branch` means the
object is tied to the same branch as the requester. All three test a **relationship** between
the principal and the object — not an attribute the principal alone carries, the way a role is.
The administrator role is reduced, in all three criteria, to the branch boundary: the
administrator's privilege is not access to every object, but access to their own branch's
objects.

The matrix, principal set, and object set below are a **model**: there is no real service, no
real endpoint, and no real subscriber.

```js
// authorization/matrix.mjs — the subscriber portal's authorization matrix (model): no real
// service, no real endpoint and no real subscriber. Roles, objects and endpoints are written
// as data.

// IE9: every endpoint has two separate checks — the role check authorizes the action type, the
// criterion check authorizes the requested object's relationship with the requester. `correct`
// is the criterion the endpoint is supposed to carry; `reported` is the criterion the code
// actually applies today (null: no object check).
export const ENDPOINT = [
  { path: "GET /invoices/{id}",              type: "invoice",     role: ["subscriber", "admin"], correct: "owner",    reported: "owner" },
  { path: "GET /invoices/{id}/items",        type: "invoice",     role: ["subscriber", "admin"], correct: "owner",    reported: null },
  { path: "POST /invoices/{id}/dispute",     type: "invoice",     role: ["subscriber"],          correct: "owner",    reported: null },
  { path: "DELETE /invoices/{id}",           type: "invoice",     role: ["admin"],               correct: "branch",   reported: "branch" },
  { path: "GET /readings/{id}",              type: "reading",     role: ["subscriber", "staff", "admin"], correct: "owner", reported: "owner" },
  { path: "GET /readings/{id}/history",      type: "reading",     role: ["subscriber", "staff"], correct: "owner",    reported: null },
  { path: "POST /readings/{id}/correction",  type: "reading",     role: ["staff", "admin"],      correct: "branch",   reported: "branch" },
  { path: "GET /subscribers/{id}/profile",   type: "subscriber",  role: ["subscriber", "admin"], correct: "owner",    reported: "owner" },
  { path: "PUT /subscribers/{id}/contact",   type: "subscriber",  role: ["subscriber"],          correct: "owner",    reported: "owner" },
  { path: "POST /subscribers/{id}/plan",     type: "subscriber",  role: ["admin"],               correct: "branch",   reported: "branch" },
  { path: "GET /work-orders/{id}",           type: "work-order",  role: ["staff", "admin"],      correct: "assigned", reported: "assigned" },
  { path: "POST /work-orders/{id}/close",    type: "work-order",  role: ["staff", "admin"],      correct: "assigned", reported: "assigned" },
  { path: "POST /work-orders/{id}/note",     type: "work-order",  role: ["staff"],               correct: "assigned", reported: null },
  { path: "GET /reports/{id}",               type: "report",      role: ["admin"],               correct: "branch",   reported: null },
];

// IE10: the principal and object sets are fictional; every object has a branch, and some have
// an owner or an assignee.
export const PRINCIPAL = [
  { code: "a-4471", role: "subscriber", branch: "S-1" },
  { code: "a-5120", role: "subscriber", branch: "S-2" },
  { code: "g-31",   role: "staff",      branch: "S-1" },
  { code: "g-47",   role: "staff",      branch: "S-2" },
  { code: "y-08",   role: "admin",      branch: "S-1" },
];
export const OBJECT = [
  { code: "f-1", type: "invoice",     owner: "a-4471", branch: "S-1" },
  { code: "f-2", type: "invoice",     owner: "a-5120", branch: "S-2" },
  { code: "o-1", type: "reading",     owner: "a-4471", branch: "S-1" },
  { code: "o-2", type: "reading",     owner: "a-5120", branch: "S-2" },
  { code: "b-1", type: "subscriber",  owner: "a-4471", branch: "S-1" },
  { code: "b-2", type: "subscriber",  owner: "a-5120", branch: "S-2" },
  { code: "i-1", type: "work-order",  assigned: "g-31", branch: "S-1" },
  { code: "i-2", type: "work-order",  assigned: "g-47", branch: "S-2" },
  { code: "r-1", type: "report",      branch: "S-1" },
  { code: "r-2", type: "report",      branch: "S-2" },
];

// IE11: the criterion tests the relationship between the principal and the object. An admin
// reaches their own branch's objects; ownership and assignment are direct code equality.
export const criterion = (name, principal, object) =>
  name === "branch" ? object.branch === principal.branch
  : principal.role === "admin" ? object.branch === principal.branch
  : name === "owner" ? object.owner === principal.code
  : object.assigned === principal.code;
export const roleAllows = (endpoint, principal) => endpoint.role.includes(principal.role);
```

```js
// authorization/coverage.mjs — how many endpoints perform an object-level check, how many
// requests from an unchecked endpoint reach someone else's object, how many the central gate
// closes, and how many legitimate requests it stops.
import { ENDPOINT, PRINCIPAL, OBJECT, criterion, roleAllows } from "./matrix.mjs";
const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));

// IE12: the request set is every (principal, endpoint, object) triple that passes the role
// check. Three setups run on the same set: the correct decision, distributed checking, and the
// central gate (default-deny when there is no criterion).
const requests = (set) => {
  const l = [];
  for (const endpoint of set) for (const p of PRINCIPAL) for (const o of OBJECT)
    if (o.type === endpoint.type && roleAllows(endpoint, p)) l.push({ endpoint, p, o });
  return l;
};
const decide = (r, setup) => {
  if (setup === "correct") return criterion(r.endpoint.correct, r.p, r.o);
  if (r.endpoint.reported) return criterion(r.endpoint.reported, r.p, r.o);
  return setup === "distributed";                     // default-deny at the central gate
};
const count = (set, setup, condition) => requests(set).filter((r) => condition(
  decide(r, setup), decide(r, "correct"))).length;

const A = [-30, 6, 9, 12, 16];
console.log(`1. ${ENDPOINT.length} endpoints: which ones perform object-level control`);
write(A, "endpoint", "roles", "correct", "reported", "wrongly passed");
for (const endpoint of ENDPOINT) {
  const y = requests([endpoint]).filter((r) => decide(r, "distributed") && !decide(r, "correct")).length;
  write(A, endpoint.path, endpoint.role.length, endpoint.correct, endpoint.reported ?? "-", y || "-");
}
const noCriterion = ENDPOINT.filter((e) => !e.reported);
console.log(`endpoints with no object-level control: ${noCriterion.length} / ${ENDPOINT.length}`);

const B = [-40, 9, 9];
console.log("\n2. the set of requests that pass the role check");
write(B, "measure", "requests", "share %");
const T = requests(ENDPOINT).length;
const row = (name, n) => write(B, name, n, ((n / T) * 100).toFixed(1));
row("requests that pass the role check", T);
row("should pass (correct decision)", count(ENDPOINT, "correct", (k) => k));
row("passing under distributed checking", count(ENDPOINT, "distributed", (k) => k));
row("distributed: wrongly passed request", count(ENDPOINT, "distributed", (k, d) => k && !d));
row("central gate: wrongly passed", count(ENDPOINT, "central", (k, d) => k && !d));
row("central gate: legitimate request stopped", count(ENDPOINT, "central", (k, d) => !k && d));

// 3. when a new endpoint is added: an endpoint added without declaring a criterion
const NEW_ENDPOINT = { path: "GET /invoice/{id}/payment-plan", type: "invoice", role: ["subscriber", "admin"],
  correct: "owner", reported: null };
const extended = [...ENDPOINT, NEW_ENDPOINT];
const C = [-31, 9, 14, 16, 20];
console.log("\n3. when a new endpoint is added without declaring a criterion");
write(C, "setup", "endpoints", "no criterion", "wrongly passed", "legitimate stopped");
for (const [name, set, setup] of [
  ["today's distributed check", ENDPOINT, "distributed"],
  ["central gate", ENDPOINT, "central"],
  ["new endpoint, distributed check", extended, "distributed"],
  ["new endpoint, central gate", extended, "central"],
]) write(C, name, set.length, set.filter((e) => !e.reported).length,
  count(set, setup, (k, d) => k && !d), count(set, setup, (k, d) => !k && d));
```

```
1. 14 endpoints: which ones perform object-level control
endpoint                       roles  correct    reported  wrongly passed
GET /invoices/{id}                 2    owner       owner               -
GET /invoices/{id}/items           2    owner           -               3
POST /invoices/{id}/dispute        1    owner           -               2
DELETE /invoices/{id}              1   branch      branch               -
GET /readings/{id}                 3    owner       owner               -
GET /readings/{id}/history         2    owner           -               6
POST /readings/{id}/correction     2   branch      branch               -
GET /subscribers/{id}/profile      2    owner       owner               -
PUT /subscribers/{id}/contact      1    owner       owner               -
POST /subscribers/{id}/plan        1   branch      branch               -
GET /work-orders/{id}              2 assigned    assigned               -
POST /work-orders/{id}/close       2 assigned    assigned               -
POST /work-orders/{id}/note        1 assigned           -               2
GET /reports/{id}                  1   branch           -               1
endpoints with no object-level control: 5 / 14

2. the set of requests that pass the role check
measure                                  requests  share %
requests that pass the role check              72    100.0
should pass (correct decision)                 32     44.4
passing under distributed checking             46     63.9
distributed: wrongly passed request            14     19.4
central gate: wrongly passed                    0      0.0
central gate: legitimate request stopped       10     13.9

3. when a new endpoint is added without declaring a criterion
setup                          endpoints  no criterion  wrongly passed  legitimate stopped
today's distributed check             14             5              14                   0
central gate                          14             5               0                  10
new endpoint, distributed check       15             6              17                   0
new endpoint, central gate            15             6               0                  13
```

The numbers belong to the **measurement** class; their inputs are IE9–IE12.

## Five Endpoints, Fourteen Requests

The first table gives the inventory: **five** of fourteen endpoints do not perform object-level
control. These are the endpoints carrying a dash in the `reported` column, and next to each one
is written how many requests wrongly pass.

Four of the five endpoints are a **sub-resource** or a **sub-action**: invoice items, reading
history, work-order note, and invoice dispute. The criterion written for the parent resource is
assumed to already apply to the sub-path, and the question is never asked there again. The
fifth, `GET /reports/{id}`, is a single-role endpoint; the single role was mistaken for the
whole of the authorization check.

The wrongly-passed request counts are not equal across endpoints. `GET /readings/{id}/history`
leaks six requests, `GET /reports/{id}` only one. The difference comes from the role count:
reading history is open to two roles, and those two roles have four principals in total, while
the report endpoint is open to one role and one principal. The risk an endpoint carries is the
product of its missing criterion and **the width of its role set**. The sentence "five endpoints
are missing" does not show this difference; the request count does.

The ranking also gives a fix order. If all five endpoints cannot be fixed the same day, the
endpoint leaking six requests comes before the one leaking two; fixing two endpoints closes 64%
of the leak. It is worth noting that this ranking cannot be seen from a code review: all five
endpoints look the same in the code, missing the same single line. What produces the difference
is not the code itself, but the intersection of the endpoint's role set with the object set.

## Fourteen of Seventy-Two Requests

The second table opens up the request set. The number of requests that pass the role check is
**72**; **32** of those are requests that should pass (44.4%). The gap between them shows what
the role check alone leaves behind: one out of every two requests that pass the role check
should be rejected under the correct decision.

The number of requests passing under distributed checking is 46. This number is 14 more than
32, and those 14 requests are the ones that **reach someone else's object** — 19.4% of the
request set. All of them can be made with a valid session, the right role, and a single path
parameter changed.

What these 14 requests have in common is that they produce no error on the server at all. The
response is 200, the record genuinely exists, the query runs correctly. The only measurable
trace is the request set itself: the same principal has gone to the same endpoint, one after
another, with identifiers that do not belong to it. The audit log can see this pattern but
cannot make the decision; the criterion is what has to make the decision.

Being able to generate the request set this way is the measurement's real gain. The set was not
written by hand; it was **derived** from the principal, endpoint, and object lists. The same
derivation is also used as a test set: for every endpoint, a "call with a principal that is not
the owner and expect a rejection" request is generated on its own, and the number of tests grows
as the number of endpoints grows. Because the criterion is data, it can both be audited and be
tested; if it were buried inside an `if` line, neither would be possible.

## The Central Gate and Its Cost

The central gate combines two things: the criterion is applied in a single place, and an
endpoint that does not report a criterion **is denied by default**. The second table's last two
rows give this setup's two columns. Wrongly passed requests are **0**; legitimate requests
stopped are **10** — 13.9% of the request set.

These ten requests are a cost, and it has to be written down. Default-deny closes all five
endpoints that are missing a criterion entirely; legitimate use of those endpoints stops too. A
subscriber requesting invoice items for their own invoice also gets a 403. But the difference
between the two kinds of error lies in observability: a stopped legitimate request produces an
error log **on day one** and gets fixed; a wrongly-passed request produces no log at all and may
go unnoticed for years. Default-deny turns a silent flaw into a noisy one.

The third table measures how this holds up over time. A fifteenth endpoint added without
declaring a criterion — `GET /invoice/{id}/payment-plan` — raises the distributed check's
wrongly-passed count from 14 to **17**. At the central gate, the same endpoint leaves the
wrongly-passed count at 0 and raises the stopped legitimate requests from 10 to 13. Coverage
stays constant; what changes is the visible cost.

The difference is not in the pattern's power, it is in **which way the default points**. In
distributed checking, forgetting to write the criterion results in an open endpoint; at the
central gate, it results in a non-working endpoint. Both are flaws, but one sits silently in
production while the other reports immediately. The central gate's measurable contribution is
not the coverage percentage — it is that **it changes what forgetting results in**.

## Summary

- The authorization question has two forms: the role authorizes the action type, and the
  criterion authorizes the requested object's relationship with the requester. When only the
  first is asked, everyone in the role set reaches every object.
- Five of fourteen endpoints do not perform object-level control; four are a sub-resource or a
  sub-action, one is a single-role endpoint.
- 32 of the 72 requests that pass the role check should pass; 46 requests pass under
  distributed checking, and the 14 in between (19.4%) reach someone else's object.
- The risk an endpoint carries is the product of its missing criterion and the width of its
  role set: the same gap leaks 6 requests at one endpoint and 1 at another.
- A central gate built on default-deny brings wrongly passed requests down to 0 and stops 10
  legitimate requests; when a fifteenth endpoint is added, wrongly passed stays at 0, while it
  rises to 17 under distributed checking.
- The central gate's measurable contribution is that it turns the result of forgetting the
  criterion from a silent gap into an observable error.

## Next Step

This lesson asked the authorization question over a single request: can this principal touch
this object, at this endpoint? All three numbers rest on the single-request assumption. Yet a
system's rules live not only in single requests but also in **sequences of requests**. A
discount is applied once, a refund is taken once, a quota is consumed once per period. None of
these rules are written at a single endpoint; they are written in the order of steps. The next
lesson models flows as a sequence of steps and asks: how many paths exist where steps can be
skipped or repeated, how many of them does a state machine close, and how many are left
uncovered.
