---
title: 'Scopes and Permissions'
source: 'https://academia.sh/en/courses/authentication-and-authorization/scopes-and-permissions'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:33+00:00'
license: 'CC BY-SA 4.0'
---

# Scopes and Permissions

Keeping a token's scope narrower than its owner's authority, the decision produced by the intersection of the scope and permission layers, responses that diverge by the reason for denial, measuring the access surface, and the narrowing rule on renewal.

The previous four lessons bound the decision to **the person**: who, in which role,
with which attributes, and in what relationship to the object. The answer to these
questions is fixed for a person. But the one making the request is often not the person
themselves: the library's mobile app, a reporting tool, a third-party reading-list
service the person has authorized.

Granting these clients the person's entire authority is unnecessary. The reading-list
service has no need to delete a loan record; the reporting tool has no need to read a
member's address. **Scope** is the mechanism that keeps a token's authority **narrower**
than its owner's authority.

## A Two-Layer Decision

Scope does not replace the authorization models from the previous lessons; it sits on
top of them. A request must pass two questions at once: **can the person do this**, and
**has this client been granted this authority**. The two come from separate sources —
the first from user management, the second from the consent given when the token was
issued.

The decision is therefore an intersection. Scope cannot widen authority: a token
carrying the `fine:write` scope means nothing if its owner has no permission to collect
fines. The reverse also holds: an authorized clerk's token cannot reach an endpoint
outside its scope.

The computation below runs these two layers over a mapping table.

```bash
cat > scope.mjs <<'EOF'
// scope.mjs — mapping token scopes onto endpoints and the two-layer decision

// 1. Endpoint -> required scope mapping. Part of the contract.
const ENDPOINTS = [
  { method: "GET",    path: "/loans",              scope: "loan:read" },
  { method: "POST",   path: "/loans",              scope: "loan:write" },
  { method: "DELETE", path: "/loans/{id}",         scope: "loan:write" },
  { method: "GET",    path: "/members/{id}",       scope: "member:read" },
  { method: "PATCH",  path: "/members/{id}",       scope: "member:write" },
  { method: "GET",    path: "/members/{id}/fines", scope: "fine:read" },
  { method: "POST",   path: "/fines/{id}/collect", scope: "fine:write" },
  { method: "GET",    path: "/reports/overdue",    scope: "report:read" },
];

// 2. Subject permissions (from the RBAC lesson): what the person can do.
const PERMISSIONS = {
  "U-1001": new Set(["loan:read", "loan:write", "member:read", "fine:read"]),
  "P-2001": new Set(["loan:read", "loan:write", "member:read", "member:write", "fine:read", "fine:write"]),
  "Y-3001": new Set(["loan:read", "loan:write", "member:read", "member:write", "fine:read", "fine:write", "report:read"]),
};

// 3. Tokens: the scope set granted to the client. Not the same thing as the person's permissions.
const TOKENS = [
  { name: "mobile-app", subject: "U-1001", scopes: ["loan:read", "loan:write", "member:read"] },
  { name: "reading-list",  subject: "U-1001", scopes: ["loan:read"] },
  { name: "reporting-tool",    subject: "P-2001", scopes: ["report:read", "loan:read"] },
  { name: "desk-app", subject: "Y-3001", scopes: ["loan:read", "loan:write", "member:write", "fine:write"] },
];

// 4. Decision: both layers must pass. Scope bounds the client's authority, permission the person's.
function decision(token, endpoint) {
  const hasScope = token.scopes.includes(endpoint.scope);
  const hasPermission = PERMISSIONS[token.subject].has(endpoint.scope);
  if (!hasScope && !hasPermission) return "deny: no scope and no permission";
  if (!hasScope) return "deny: token lacks scope";
  if (!hasPermission) return "deny: subject lacks permission";
  return "allow";
}

const pad = (s, n) => String(s).padEnd(n);
console.log("token              endpoint                          result");
for (const t of TOKENS) {
  for (const endpoint of ENDPOINTS) {
    const s = decision(t, endpoint);
    if (s === "allow") continue;                       // show only the denied ones
    if (!PERMISSIONS[t.subject].has(endpoint.scope) && !t.scopes.includes(endpoint.scope)) continue;
    console.log(`${pad(t.name, 18)} ${pad(endpoint.method + " " + endpoint.path, 33)} ${s}`);
  }
}

// 5. The scope as an access surface: how many endpoints can each token reach?
console.log("\ntoken              scopes  allowed endpoints  surface");
for (const t of TOKENS) {
  const allowed = ENDPOINTS.filter((endpoint) => decision(t, endpoint) === "allow");
  const percent = ((100 * allowed.length) / ENDPOINTS.length).toFixed(0);
  console.log(`${pad(t.name, 18)} ${pad(t.scopes.length, 7)} ${pad(allowed.length + " / " + ENDPOINTS.length, 16)} %${percent}`);
}

// 6. The effect of scope granularity: coarse scope surface vs fine scope surface.
const COARSE = ["loan:*", "member:*"];
const coarseMatches = (endpoint) => COARSE.some((k) => endpoint.scope.startsWith(k.slice(0, -1)));
const coarseSurface = ENDPOINTS.filter(coarseMatches).length;
const fineSurface = ENDPOINTS.filter((endpoint) => ["loan:read", "member:read"].includes(endpoint.scope)).length;
console.log(`\ncoarse scope ${JSON.stringify(COARSE)} -> ${coarseSurface} / ${ENDPOINTS.length} endpoints`);
console.log(`fine scope ["loan:read","member:read"] -> ${fineSurface} / ${ENDPOINTS.length} endpoints`);
console.log(`granting a coarse scope to a client that wants read access opens ${coarseSurface - fineSurface} extra endpoints.`);

// 7. Narrowing on renewal: the client can request less scope, never more.
function renew(token, requested) {
  const outside = requested.filter((k) => !token.scopes.includes(k));
  return outside.length
    ? { result: "deny", reason: "requested scope outside the original token: " + outside.join(", ") }
    : { result: "accept", scopes: requested };
}
const mobile = TOKENS[0];
console.log("\nrenewal attempts (original scope: " + JSON.stringify(mobile.scopes) + ")");
for (const requested of [["loan:read"], ["loan:read", "member:read"], ["loan:read", "fine:write"]]) {
  const r = renew(mobile, requested);
  console.log(`  ${pad(JSON.stringify(requested), 34)} ${r.result}${r.reason ? " — " + r.reason : ""}`);
}
EOF
node scope.mjs
```

```text
token              endpoint                          result
mobile-app         GET /members/{id}/fines           deny: token lacks scope
reading-list       POST /loans                       deny: token lacks scope
reading-list       DELETE /loans/{id}                deny: token lacks scope
reading-list       GET /members/{id}                 deny: token lacks scope
reading-list       GET /members/{id}/fines           deny: token lacks scope
reporting-tool     POST /loans                       deny: token lacks scope
reporting-tool     DELETE /loans/{id}                deny: token lacks scope
reporting-tool     GET /members/{id}                 deny: token lacks scope
reporting-tool     PATCH /members/{id}               deny: token lacks scope
reporting-tool     GET /members/{id}/fines           deny: token lacks scope
reporting-tool     POST /fines/{id}/collect          deny: token lacks scope
reporting-tool     GET /reports/overdue              deny: subject lacks permission
desk-app           GET /members/{id}                 deny: token lacks scope
desk-app           GET /members/{id}/fines           deny: token lacks scope
desk-app           GET /reports/overdue              deny: token lacks scope

token              scopes  allowed endpoints  surface
mobile-app         3       4 / 8            %50
reading-list       1       1 / 8            %13
reporting-tool     2       1 / 8            %13
desk-app           4       5 / 8            %63

coarse scope ["loan:*","member:*"] -> 5 / 8 endpoints
fine scope ["loan:read","member:read"] -> 2 / 8 endpoints
granting a coarse scope to a client that wants read access opens 3 extra endpoints.

renewal attempts (original scope: ["loan:read","loan:write","member:read"])
  ["loan:read"]                      accept
  ["loan:read","member:read"]        accept
  ["loan:read","fine:write"]         deny — requested scope outside the original token: fine:write
```

## The Reason for Denial Must Be Distinguishable

The first part of the output lists denied requests together with their reasons. The two
reasons are distinct, and the distinction tells a developer different things.

"Token lacks scope" is a recoverable situation: the client can ask the user for
additional consent and obtain a token with a broader scope. "Subject lacks permission"
is not recoverable — no consent flow can grant a clerk permission to read the overdue
report, because that permission is defined in user management. The reporting-tool row is
an example of this: the token carries the `report:read` scope, but the subject has no
permission, so the request is denied.

The response sent to the client must carry this distinction. For insufficient scope, the
standard is a response stating which scope is required; for insufficient permission, no
extra detail is given — spelling out what the user is not authorized for leaks the
authority map to the outside.

## Scope Is an Access Surface

The second part counts how many endpoints each token can reach. This number is a direct
measure of how much damage can be done if the token leaks: the reading-list service's
token reaches one of eight endpoints, the desk app's token reaches five.

The surface being measurable is a design tool. Before a client's requested scope set is
approved, the question to ask is: is the number of endpoints this set opens
proportional to what the client actually does? If the reading-list service only reads
loan history, `loan:read` is enough; asking for `loan:write` triples the surface and has
no justification.

## Grain Size Is a Decision

The third part compares a coarse and a fine scope. Defining a single scope per
resource — something like `loan:*` — shortens the scope list, but also grants write
authority to a client that only wants to read; in the model, that means three extra
endpoints.

At the opposite extreme, defining a separate scope for every endpoint does not work
either: as the scope list grows, the consent screen becomes unreadable and the user
approves it without reading it. The grain size that works is the **resource-and-action**
pair: `loan:read`, `loan:write`. At this grain, the scope count is twice the resource
count, and every scope's meaning can be stated in one sentence.

The scope's own name is also part of the contract. When the endpoint changes, the scope
name stays fixed; when the scope name changes, every client must obtain consent again.
This is the authorization side of the breaking-change rule from the Versioning
Strategies lesson.

## Renewal Narrows; It Does Not Widen

The last part shows renewal behavior. A client renewing its current token may request a
**narrower** scope set; this makes it possible to run with less authority temporarily
during a long-lived session. It may not request a **broader** set: widening requires a
new consent flow.

The rule is written in one sentence: a renewed token's scope must be a subset of the
original token's scope. Not enforcing this rule turns the renewal endpoint into a
silent privilege-escalation path — because renewal is a flow that runs without user
consent.

## Summary

- Scope keeps a token's authority narrower than its owner's authority; the decision is
  the intersection of the two layers, and scope never widens authority.
- Insufficient scope and insufficient permission produce different responses: the first
  states which scope is required, the second gives no detail.
- The number of endpoints a token reaches is the measure of the damage a leak could do;
  in the model, token surfaces range from thirteen to sixty-three percent.
- The grain is kept at the resource-action level: a coarse scope opens three extra
  endpoints to a client that wants to read, an overly fine scope makes the consent
  screen unreadable.
- On renewal, scope can only be narrowed; widening requires new consent, otherwise
  renewal turns into a silent privilege-escalation path.

## Next Step

Every decision made so far has been given inside a single library: one member, one
clerk, one branch. Once the same server serves more than one library, a new boundary
enters the picture, and this boundary is drawn not by role or scope but by **data**: one
library's clerk should be able to see their own institution's records and should never
be able to see a neighboring institution's records under any condition. The next lesson
takes up this isolation and shows how the boundary is carried into the query itself.
