---
title: 'Authentication and Authorization'
source: 'https://academia.sh/en/courses/authentication-and-authorization'
language: en
updated: '2026-08-19T05:19:31+00:00'
license: 'CC BY-SA 4.0'
lessons: 23
outcomes:
    - 'Separates authentication from authorization and selects the appropriate flow'
    - 'Implements password and token storage securely'
    - 'Builds the authorization model on a role, attribute or relationship basis'
---

# Authentication and Authorization

Establishing the identity behind a request and deciding what that identity is allowed to do: session and token flows, signature verification, refresh and exchange, delegation protocols, storing credentials, and role-, attribute- or relationship-based authorization models.

## Authentication Flows

Determining on the server who the request belongs to: setting up flows from the basic scheme to the session cookie, from the signed token to refresh exchange, from delegation and federation protocols to machine keys and the second factor, the observable consequence of getting the setup wrong, and the criteria for choosing among them.

### Authentication and Authorization Distinction

Source: https://academia.sh/en/courses/authentication-and-authorization/authentication-and-authorization-distinction

Showing that determining the principal behind a request and deciding what that principal can do are two separate questions: the observable consequence of a server that asks only the first one, the distinction between 401 and 403, and building the decision on deny by default.

Throughout the previous course, the loan service's contract was established: which
address returns which resource, which method changes what, in what shape an error is
reported, and how the contract is documented and tested. None of these decisions ever
asked one question: who is sending this request? The mock server gave every request the
same response, and the contract test ran every interaction with the same privilege. The
contract was written independent of who the caller was.

In a real service, this independence does not hold. A member can read a loan record for
themselves, only staff can see the overdue report, and only a branch manager can clear a
member's fine. Each of these sentences carries two separate pieces of information: what
the identity behind the request is, and what that identity is allowed to do. This course
begins by separating these two pieces of information, because a server that treats them
as one question always breaks the same way.

#### Two Separate Questions

**Authentication** answers the question "who are you?" Starting from the **credential**
the request carries, the server determines the **principal** behind the request. A
principal can be a person, a service account, or a shelf terminal. This step's output is
exactly one thing: an identity, or none.

**Authorization** answers the question "can you do this?" Its input is the principal,
the requested action, and the action's target; its output is a decision: allow or deny.
This step does not determine identity itself — it uses what the first step found.

The order cannot be reversed. An authorization decision cannot be made without knowing
the identity, because the decision's input is missing. But the reverse happens often:
the identity is determined correctly, and then the authorization question is never
asked. The server treats the principal's existence as sufficient and unknowingly applies
the rule "anyone registered can do anything." This rule's consequence does not show up
in the code — it only shows up once requests are run.

#### The Server That Asks Only the First Question

The server below serves three endpoints and determines identity correctly: an
unrecognized credential gets a 401. The authorization check, however, sits behind a
flag; when the flag is not given, the second question is never asked.

```js
// loans.mjs — loan service; authentication present, authorization optional
// Usage: node loans.mjs <port> [authz]
//   authz: when given, the role check also runs
import { createServer } from "node:http";

const PORT = Number(process.argv[2] ?? 8501);
const AUTHZ = process.argv[3] === "authz";

// Credential -> principal. This mapping is the subject of later lessons;
// here identity is assumed to already be determined.
const CREDENTIALS = new Map([
  ["k-member-1001", { code: "U-1001", name: "Clara Diaz", role: "member" }],
  ["k-staff-7", { code: "P-0007", name: "Nora Bishop", role: "staff" }],
  ["k-manager-2", { code: "Y-0002", name: "Marcus Feld", role: "branch-manager" }],
]);

const LOANS = new Map([
  ["O-1", { id: "O-1", member: "U-1001", isbn: "978-0262033848", dueDate: "2026-03-20", status: "open" }],
  ["O-2", { id: "O-2", member: "U-1042", isbn: "978-0201896831", dueDate: "2026-02-11", status: "overdue" }],
]);

// Endpoint -> roles that can call it
const PERMISSIONS = new Map([
  ["GET /loans", ["member", "staff", "branch-manager"]],
  ["GET /reports/overdue", ["staff", "branch-manager"]],
  ["POST /fines/delete", ["branch-manager"]],
]);

createServer((request, response) => {
  response.sendDate = false;
  const path = request.url.split("?")[0];
  const json = (code, body, headers = {}) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8", ...headers });
    response.end(JSON.stringify(body));
  };

  // Question 1: who? — 401 if there is no credential or it is unrecognized
  const credential = (request.headers.authorization ?? "").replace(/^Identity /, "");
  const principal = CREDENTIALS.get(credential);
  if (!principal) {
    return json(401, { error: "could not authenticate" }, { "www-authenticate": 'Identity realm="loans"' });
  }

  // Endpoint name
  const endpoint = path.startsWith("/loans/") ? "GET /loans"
    : path === "/reports/overdue" ? "GET /reports/overdue"
    : path === "/fines/delete" ? "POST /fines/delete"
    : null;
  if (!endpoint || !endpoint.startsWith(request.method)) return json(404, { error: "no such resource" });

  // Question 2: what can they do? — only asked when AUTHZ is on
  if (AUTHZ && !PERMISSIONS.get(endpoint).includes(principal.role)) {
    return json(403, { error: "not authorized for this operation", role: principal.role, required: PERMISSIONS.get(endpoint) });
  }

  if (endpoint === "GET /loans") {
    const record = LOANS.get(path.slice("/loans/".length));
    return record ? json(200, record) : json(404, { error: "no such record" });
  }
  if (endpoint === "GET /reports/overdue") {
    return json(200, { overdue: [...LOANS.values()].filter((k) => k.status === "overdue") });
  }
  return json(200, { deleted: true, handledBy: principal.code });
}).listen(PORT, "127.0.0.1", () => console.log(`loans 127.0.0.1:${PORT} authz=${AUTHZ}`));
```

A small client that tries every role against every endpoint is enough to read the
decision table.

```js
// matrix.mjs — tabulates the status code each role gets from each endpoint
// Usage: while node loans.mjs 8501 &  and  node loans.mjs 8502 authz &  are running
//        node matrix.mjs 8501    /    node matrix.mjs 8502
const PORT = Number(process.argv[2] ?? 8501);
const BASE = `http://127.0.0.1:${PORT}`;

const ROLES = [["member", "k-member-1001"], ["staff", "k-staff-7"], ["manager", "k-manager-2"], ["unknown", "k-none"]];
const ENDPOINTS = [["GET", "/loans/O-1"], ["GET", "/reports/overdue"], ["POST", "/fines/delete"]];

console.log("role".padEnd(10) + ENDPOINTS.map(([m, p]) => `${m} ${p}`.padEnd(26)).join(""));
for (const [name, credential] of ROLES) {
  const codes = [];
  for (const [method, path] of ENDPOINTS) {
    const response = await fetch(BASE + path, { method, headers: { authorization: `Identity ${credential}` } });
    codes.push(String(response.status).padEnd(26));
  }
  console.log(name.padEnd(10) + codes.join(""));
}
```

Two servers are brought up at the same time; one without the authorization check, one
with it.

```bash
node loans.mjs 8501 > /dev/null & UNCHECKED=$!
node loans.mjs 8502 authz > /dev/null & CHECKED=$!
sleep 1
echo "— without the authorization check —"
node matrix.mjs 8501
echo "— with the authorization check —"
node matrix.mjs 8502
kill $UNCHECKED $CHECKED
```

```
— without the authorization check —
role      GET /loans/O-1            GET /reports/overdue      POST /fines/delete
member    200                       200                       200
staff     200                       200                       200
manager   200                       200                       200
unknown   401                       401                       401
— with the authorization check —
role      GET /loans/O-1            GET /reports/overdue      POST /fines/delete
member    200                       403                       403
staff     200                       200                       403
manager   200                       200                       200
unknown   401                       401                       401
```

In the first table, three of the four rows are identical. Authentication works: an
unrecognized credential gets a 401 at every endpoint. The only distinction the check
makes is between "registered" and "unregistered." A request arriving with a member's
credential reads the entire overdue report and deletes a fine; the response's
`handledBy` field says the deletion was carried out on behalf of U-1001.

This is not a flaw in authentication; authentication has done its job. The flaw is that
the authorization decision was never made. In the second table, the same requests get a
403, and the table now contains three different rows. The only difference is that the
code handling the request asks the second question.

#### The 401 and 403 Distinction

The two status codes correspond to two different questions and cannot substitute for
one another.

**401** reports that the first question went unanswered: there is no credential, or it
is malformed, or it is unrecognized. The response must carry the `WWW-Authenticate`
header, which states how the identity should be presented; without this header, it
stays unclear what the client should do.

```bash
node loans.mjs 8502 authz > /dev/null & SERVER=$!
sleep 1
curl -s -D - -o /dev/null http://127.0.0.1:8502/fines/delete | head -3
kill $SERVER
```

```
HTTP/1.1 401 Unauthorized
content-type: application/json; charset=utf-8
www-authenticate: Identity realm="loans"
```

**403** reports that the first question was answered but the second one returned a
denial. The identity is known and valid; what is missing is authorization. Sending the
same credential again does not change the outcome, because the problem is not with the
credential.

```bash
node loans.mjs 8502 authz > /dev/null & SERVER=$!
sleep 1
curl -s -w ' %{http_code}\n' -X POST -H 'Authorization: Identity k-member-1001' http://127.0.0.1:8502/fines/delete
kill $SERVER
```

```
{"error":"not authorized for this operation","role":"member","required":["branch-manager"]} 403
```

This distinction determines the client's behavior. A client that gets a 401 refreshes
its credential and retries; a client that gets a 403 does not retry — it tells the user
that their authorization is insufficient. A server that confuses the codes, returning a
401 instead of a 403, puts the client into an endless refresh loop.

A third option also exists: if a resource's very **existence** is confidential
information, an unauthorized request may need a 404 instead of a 403. A 403 says "such a
resource exists, but it is closed to you," and in some cases that statement is
information that should not be disclosed. This choice must be made deliberately;
returning a 404 everywhere also deafens debugging.

#### Where the Decision Is Made

The authorization decision has a **decision point** in the code. In the server above,
this point is a single `if` block, and it comes before the code that handles the
request. Two of its properties matter.

First, the decision point is built on **deny by default**. An endpoint with no entry in
the `PERMISSIONS` table is open to no one. In the opposite setup — "anything not
forbidden in the table is allowed" — every new endpoint that was forgotten from the
table is open to everyone the moment it is written. Adding a new endpoint is a frequent
task; forgetting to add it is just as frequent. Deny by default turns the consequence of
forgetting from an access hole into a broken endpoint: the outcome is still an error,
but it is an observable, harmless one.

Second, the decision point must be **singular**. In code where the same decision is
made in two places, the two copies drift apart over time, and which one holds only turns
up by running the request. The middleware chain — the structure built in the Server
Fundamentals course — is the natural place to guarantee this singularity: the layer that
determines identity attaches a principal to the request, the authorization layer looks
at that principal, and the business logic never recomputes either.

These two questions are not the whole of the check. In the second table's `member` row,
the `GET /loans/O-1` cell is 200, and correctly so: members can read loan records. But
the same member can also read someone else's record.

```bash
node loans.mjs 8502 authz > /dev/null & SERVER=$!
sleep 1
curl -s -w ' %{http_code}\n' -H 'Authorization: Identity k-member-1001' http://127.0.0.1:8502/loans/O-2
kill $SERVER
```

```
{"id":"O-2","member":"U-1042","isbn":"978-0201896831","dueDate":"2026-02-11","status":"overdue"} 200
```

The request that arrived with the U-1001 identity got U-1042's member record. The role
check passed because the question was asked as "can members read loan records?" — the
question that should have been asked was "can this member read this record?" A role
authorizes the type of action; it does not authorize ownership of the object. This
distinction is a lesson of its own in the Authorization Models topic; here it is enough
to note that the authorization question is asked of a principal–action–object triple,
not on an endpoint's behalf.

#### Summary

- Authentication determines the principal behind the request; authorization decides
  whether that principal can perform a specific action. The second one's input is the
  first one's output.
- A server that only authenticates applies the rule "anyone registered can do
  anything"; this rule does not appear in the code — it appears in the access table.
- 401 reports that the credential is missing or unrecognized and carries the
  `WWW-Authenticate` header; 403 reports that the identity is known but the
  authorization is insufficient.
- If a resource's existence is confidential information, returning a 404 for an
  unauthorized request is a deliberate option — it is not the default.
- The decision point is built on deny by default and is singular; an endpoint not
  added to the table stays closed.
- A role check authorizes the type of action, not ownership of the object; the
  authorization question is asked of a principal–action–object triple.

#### Next Step

In this lesson, how identity is determined was hidden behind a lookup table: the
request carried a string, and the server turned it into a principal. In a real setup,
where this string comes from, how it is carried, and how long it stays valid are
separate decisions. The oldest and smallest answer is the basic authentication scheme
defined within HTTP itself: a username and password are carried in the `Authorization`
header on every request. The next lesson builds this scheme by running it, shows why the
carried value is not considered encrypted, and marks out where the scheme is sufficient
and where it falls short.

### Basic Authentication

Source: https://academia.sh/en/courses/authentication-and-authorization/basic-authentication

Building HTTP's own basic authentication scheme: the 401 challenge and the WWW-Authenticate header, showing that the carried value is a decodable encoding, constant-time comparison, and the consequences of the credential passing on every request.

In the previous lesson, identity was hidden behind a lookup table: the request carried a
string, and the server turned it into a principal. The question of where that string
came from was left open. HTTP has its own answer to this question, and the smallest of
its answers is the **basic authentication scheme**: a username and password are carried
in the `Authorization` header on every request.

The scheme is small, and that is exactly what makes it instructive. It makes every part
of authentication visible: how the server requests the credential, how the client sends
it, how the server compares it, and how long the credential stays exposed. Most of the
problems the following lessons solve are problems this scheme does not solve.

#### The Scheme's Two Steps

The scheme begins with a **challenge**. If the request carries no credential, the
server returns a 401 and states which scheme it expects in the `WWW-Authenticate`
header. The header's `realm` parameter names the **realm** for which the credential is
valid; the same server can hold more than one realm, and the client uses this name to
tell which credential belongs to which realm. The `charset="UTF-8"` parameter states
which encoding turns the username and password into bytes.

In the second step, the client responds with the `Authorization: Basic <value>` header.
The value is the base64 encoding of the `username:password` string.

```js
// basic.mjs — HTTP basic authentication scheme (RFC 7617)
// Usage: node basic.mjs <port>
import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8511);

// In a real setup the password is stored as a verifier, not directly;
// this lesson explains the scheme, not storage.
const ACCOUNTS = new Map([
  ["shelf-terminal", "Kt-9f3a-Shelf!"],
  ["clara.diaz", "café-books-1876"],
]);

// Constant-time comparison: the length difference must not leak any more than inequality does
const isEqual = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

let requestCount = 0, passwordSeenCount = 0;

createServer((request, response) => {
  response.sendDate = false;
  requestCount++;
  const json = (code, body, headers = {}) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8", ...headers });
    response.end(JSON.stringify(body));
  };
  const reject = () => json(401, { error: "could not authenticate" },
    { "www-authenticate": 'Basic realm="loans", charset="UTF-8"' });

  const header = request.headers.authorization ?? "";
  if (!header.startsWith("Basic ")) return reject();

  // Basic <base64(username:password)> — decoding requires no key
  const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
  const separator = decoded.indexOf(":");
  if (separator < 0) return reject();
  const username = decoded.slice(0, separator), password = decoded.slice(separator + 1);
  passwordSeenCount++;                        // the password reached the server on this request too

  const expected = ACCOUNTS.get(username);
  if (expected === undefined || !isEqual(password, expected)) return reject();

  if (request.url === "/counter") return json(200, { requests: requestCount, passwordSeenCount });
  json(200, { username, resource: request.url });
}).listen(PORT, "127.0.0.1", () => console.log(`basic 127.0.0.1:${PORT}`));
```

```bash
node basic.mjs 8511 > /dev/null & SERVER=$!
sleep 1
echo "--- no credential"
curl -s -D - -o /dev/null http://127.0.0.1:8511/loans/O-1 | head -3
echo "--- with curl -u"
curl -s -w " %{http_code}\n" -u "shelf-terminal:Kt-9f3a-Shelf!" http://127.0.0.1:8511/loans/O-1
echo "--- the header curl sends"
curl -s -o /dev/null -u "shelf-terminal:Kt-9f3a-Shelf!" -v http://127.0.0.1:8511/loans/O-1 2>&1 | grep -i "^> authorization"
echo "--- wrong password"
curl -s -w " %{http_code}\n" -u "shelf-terminal:wrong" http://127.0.0.1:8511/loans/O-1
echo "--- password with a multibyte character"
curl -s -w " %{http_code}\n" -u "clara.diaz:café-books-1876" http://127.0.0.1:8511/loans/O-1
kill $SERVER
```

```
--- no credential
HTTP/1.1 401 Unauthorized
content-type: application/json; charset=utf-8
www-authenticate: Basic realm="loans", charset="UTF-8"
--- with curl -u
{"username":"shelf-terminal","resource":"/loans/O-1"} 200
--- the header curl sends
> Authorization: Basic c2hlbGYtdGVybWluYWw6S3QtOWYzYS1TaGVsZiE=
--- wrong password
{"error":"could not authenticate"} 401
--- password with a multibyte character
{"username":"clara.diaz","resource":"/loans/O-1"} 200
```

The password containing a multibyte character passed, because both the client and the
server assumed UTF-8. If this assumption were not stated in the header, the two sides
could produce different bytes and the correct password could be rejected; that is the
`charset` parameter's function.

#### What the Carried Value Is

The value in the header is an encoding, not encryption. The encoding's function is to
turn arbitrary bytes into characters that can be carried in a header — not to hide
them. Showing this needs no server — the header produced by the `curl` output above is
enough by itself.

```js
// decode.mjs — looks at what the value carried by the basic scheme is
// The header value is the same one produced by curl -u "shelf-terminal:Kt-9f3a-Shelf!".
const HEADER = "Basic c2hlbGYtdGVybWluYWw6S3QtOWYzYS1TaGVsZiE=";

const body = HEADER.slice(6);
console.log("carried value :", body);
console.log("decoded       :", Buffer.from(body, "base64").toString("utf8"));
console.log("key needed to decode:", "none");

// Reverse direction: producing the same value needs no key either
const reproduced = Buffer.from("shelf-terminal:Kt-9f3a-Shelf!", "utf8").toString("base64");
console.log("reproduced:", reproduced, "| same:", reproduced === body);
```

```
carried value : c2hlbGYtdGVybWluYWw6S3QtOWYzYS1TaGVsZiE=
decoded       : shelf-terminal:Kt-9f3a-Shelf!
key needed to decode: none
reproduced: c2hlbGYtdGVybWluYWw6S3QtOWYzYS1TaGVsZiE= | same: true
```

This output has exactly one consequence: the password's confidentiality is left
entirely to the transport layer. Without the confidentiality and integrity guarantees
built in the How the Internet Works course's The Role of HTTPS lesson, the basic
scheme puts the password on the network in readable form. A configuration that opens
this scheme over an unencrypted connection must be rejected; the scheme itself has no
part that compensates for this.

The same conclusion has a second face: the password stays readable at **every
intermediate component** along the path to the server. A reverse proxy, an access log,
an error-tracing record, a browser developer tool — all of them see this header. A
setup that writes every header to the access log has also written the password to disk.

```js
// log.mjs — how the request log leaks the credential, and how that is closed off
// Usage: node log.mjs <port>   (requests write a log line to stdout)
import { createServer } from "node:http";

const PORT = Number(process.argv[2] ?? 8512);
const REDACTED_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);

const rawLine = (request) =>
  JSON.stringify({ path: request.url, headers: request.headers });

const redactedLine = (request) => {
  const headers = Object.fromEntries(
    Object.entries(request.headers).map(([name, value]) => [name, REDACTED_HEADERS.has(name) ? "<redacted>" : value]),
  );
  return JSON.stringify({ path: request.url, headers });
};

createServer((request, response) => {
  response.sendDate = false;
  console.log("raw      :", rawLine(request));
  console.log("redacted :", redactedLine(request));
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
  response.end('{"ok":true}');
}).listen(PORT, "127.0.0.1");
```

```bash
node log.mjs 8512 & SERVER=$!
sleep 1
curl -s -o /dev/null -H "user-agent: shelf-terminal/1" -u "shelf-terminal:Kt-9f3a-Shelf!" http://127.0.0.1:8512/loans/O-1
sleep 0.3
kill $SERVER
```

```
raw      : {"path":"/loans/O-1","headers":{"host":"127.0.0.1:8512","authorization":"Basic c2hlbGYtdGVybWluYWw6S3QtOWYzYS1TaGVsZiE=","accept":"*/*","user-agent":"shelf-terminal/1"}}
redacted : {"path":"/loans/O-1","headers":{"host":"127.0.0.1:8512","authorization":"<redacted>","accept":"*/*","user-agent":"shelf-terminal/1"}}
```

The redaction list works by header name and covers `cookie` and `proxy-authorization`
alongside `authorization`; all three carry a credential. This list must live in the
logging setup itself — a rule applied by hand at every logging call is breached the
first time a call is forgotten.

#### Constant-Time Comparison

The server's `isEqual` function does not compare the password with `===`. A string
comparison stops at the first differing character; this ties the comparison's duration
to the length of the correct prefix. Duration is a measurable channel and can expose
the correct prefix step by step.

`timingSafeEqual` compares every byte in every case and makes the duration independent
of the input's content. The function requires equal length, so the length check is done
separately, and a length difference is not considered secret information on its own.
The rule applies to every comparison involving a credential: password, session
identifier, signature, API key, single-use code. The request-token comparison in the
Frontend Quality course applies the same rule.

#### The Credential Passing on Every Request

The basic scheme's most defining property is that the server remembers nothing between
requests. Authentication is done from scratch on every request, and so the password is
sent again on every request. The server's `passwordSeenCount` counter measures this.

```bash
node basic.mjs 8511 > /dev/null & SERVER=$!
sleep 1
for path in /loans/O-1 /loans/O-2 /books/978-0262033848 /members/U-1001; do
  curl -s -o /dev/null -u "shelf-terminal:Kt-9f3a-Shelf!" "http://127.0.0.1:8511$path"
done
curl -s -u "shelf-terminal:Kt-9f3a-Shelf!" http://127.0.0.1:8511/counter
echo
kill $SERVER
```

```
{"requests":5,"passwordSeenCount":5}
```

Five requests means the password went out over the network five times. This has three
consequences.

First, the exposure surface grows with the number of requests. A single log, a single
misconfiguration, a single request sent to the wrong destination hands over the
password itself — whereas in the schemes in the following lessons, what leaks is a
derivative with a limited lifetime.

Second, there is no operation called **logout**. Because the server holds no record to
delete, there is no counterpart to a "cut this client's access now" request; the only
way is to change the password. When the password changes, every client using that
password drops at the same time.

Third, the scheme does not separate the account from the client. If the same account
is used by three shelf terminals, the server sees a single username; which terminal did
what, and which one's access should be cut, cannot be told apart. If the distinction is
wanted, a separate account must be opened for each client, which ties the number of
accounts to the number of clients.

#### Where the Scheme Is Sufficient

These limits do not make the scheme unusable; they narrow its range of use. The basic
scheme is sufficient when **all** of the following conditions hold:

- The connection is protected with TLS, and the endpoint never opens over an
  unencrypted connection.
- The credential is not a person's password but a randomly generated value assigned
  to a single client; so revocation drops that client and affects no one else.
- The credential is stored on the server as a verifier, not as plain text.
- The `Authorization` header is on the redaction list in the logging and
  error-tracing setup.
- There is no requirement such as a second factor, session continuity, or a
  user-visible logout.

The last condition does not hold in most user-facing applications. If a member is
logging in from a browser, session continuity, a logout button, and a second factor are
expected; the basic scheme provides none of the three. By contrast, in a setup where two
services call each other, TLS is mandatory, and the credential belongs to a machine, the
scheme can be preferable precisely because it is both sufficient and has fewer moving
parts.

#### Summary

- The basic scheme has two steps: the challenge the server sends with a 401 and the
  response the client gives with the `Authorization` header; `realm` states the realm,
  `charset` states the encoding.
- The value carried in the header is base64 encoding; decoding it requires no key, so
  confidentiality depends entirely on the transport layer.
- If headers carrying a credential are written to the log as they are, the password
  is written to disk too; the redaction list must be part of the logging setup.
- The credential comparison is done in constant time; a comparison that exits early
  reflects the correct prefix's length into its duration.
- The password passes again on every request; the consequences are a growing exposure
  surface, the absence of a logout operation, and the inability to separate the
  account from the client.
- The scheme is sufficient for machine-to-machine calls together with a TLS
  requirement and a client-specific random credential; it is insufficient for flows
  that require a session, a logout, and a second factor.

#### Next Step

The common source of the problems the basic scheme does not solve is that the server
remembers nothing between requests. If the server authenticates once and keeps the
result on its own side, the password no longer goes out over the network again, logout
gains meaning, and the same account's different clients can be tracked separately. The
next lesson builds this record: at login, a session is opened on the server side, the
client is given only an identifier pointing to that record, and this identifier is
carried by a cookie. Which attributes the cookie is sent with, and why the session
identifier is renewed at login, are the two details that determine whether the setup is
correct.

### Session-Based Authentication

Source: https://academia.sh/en/courses/authentication-and-authorization/session-based-authentication

Building a server-held session record and carrying the session identifier with a cookie: verifying cookie attributes in the response header, measuring identifier renewal at login, logout taking immediate effect, and idle and absolute lifetime limits.

The common source of the problems the basic scheme does not solve was that the server
remembered nothing between requests. The password passed again on every request, there
was no operation called logout, and the same account's different clients could not be
told apart. If the server authenticates once and keeps the result on its own side, all
three change.

The structure built in this lesson is the **session**: a record is opened on the
server at login, the record is given a random **session identifier**, and the client
carries only this identifier on subsequent requests. The password no longer goes out
over the network. The identifier itself carries no meaning; its only function is to
point to the record on the server. The two details that determine whether the setup is
correct are which attributes the identifier is carried with, and whether it is renewed
at login.

#### The Session Store

The server below holds a session store, opens a record at login, sets a cookie, and on
every request turns the cookie's identifier into a record. It takes two flags: `secure`
sends the cookie for a setup behind TLS; `fixed` produces the wrong setup that does not
renew the identifier at login, and is this lesson's counter-example.

```js
// session.mjs — server-side session store and the session identifier carried by a cookie
// Usage: node session.mjs <port> [secure] [fixed]
//   secure: cookie is sent with the __Host- prefix and the Secure attribute (TLS setup)
//   fixed : session identifier is NOT renewed at login (wrong setup; this lesson's counter-example)
// Env: IDLE_MS (idle timeout), ABSOLUTE_MS (absolute lifetime)
import { createServer } from "node:http";
import { randomBytes, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8521);
const SECURE = process.argv.includes("secure");
const FIXED = process.argv.includes("fixed");
const IDLE_MS = Number(process.env.IDLE_MS ?? 15 * 60_000);
const ABSOLUTE_MS = Number(process.env.ABSOLUTE_MS ?? 8 * 3_600_000);

const COOKIE_NAME = SECURE ? "__Host-loan_session" : "loan_session";
const ACCOUNTS = new Map([["clara.diaz", { password: "café-books-1876", code: "U-1001", role: "member" }]]);

// Session store: id -> record. A Map in a single process; a shared store across multiple processes.
const STORE = new Map();
const newId = () => randomBytes(32).toString("base64url");

const isEqual = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

const readCookie = (header, name) =>
  (header ?? "").split(";").map((p) => p.trim().split("="))
    .filter(([k]) => k === name).map(([, v]) => v)[0];

const writeCookie = (id, lifetimeSec) => {
  const parts = [`${COOKIE_NAME}=${id}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${lifetimeSec}`];
  if (SECURE) parts.splice(2, 0, "Secure");
  return parts.join("; ");
};

const resolveSession = (request) => {
  const id = readCookie(request.headers.cookie, COOKIE_NAME);
  if (!id) return { id: null, record: null };
  const record = STORE.get(id);
  if (!record) return { id, record: null };
  const now = Date.now();
  if (now - record.lastAccess > IDLE_MS || now - record.opened > ABSOLUTE_MS) {
    STORE.delete(id);                       // an expired record is dropped from the store
    return { id, record: null };
  }
  record.lastAccess = now;
  return { id, record };
};

const readBody = (request) =>
  new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(v)); });

createServer(async (request, response) => {
  response.sendDate = false;
  const path = request.url.split("?")[0];
  const json = (code, body, headers = {}) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8", ...headers });
    response.end(JSON.stringify(body));
  };

  if (request.method === "POST" && path === "/login") {
    const { username, password } = JSON.parse((await readBody(request)) || "{}");
    const account = ACCOUNTS.get(username ?? "");
    if (!account || !isEqual(password ?? "", account.password)) return json(401, { error: "could not authenticate" });

    const { id: previousId } = resolveSession(request);
    let id;
    if (FIXED && previousId) {
      id = previousId;                   // wrong: pre-login identifier is retained
    } else {
      if (previousId) STORE.delete(previousId);   // correct: the old record is deleted
      id = newId();                               // correct: a new identifier is generated
    }
    STORE.set(id, { principal: { code: account.code, role: account.role }, opened: Date.now(), lastAccess: Date.now() });
    return json(200, { loggedInAs: account.code }, { "set-cookie": writeCookie(id, Math.floor(ABSOLUTE_MS / 1000)) });
  }

  if (request.method === "POST" && path === "/logout") {
    const { id } = resolveSession(request);
    if (id) STORE.delete(id);           // the record is deleted — the decision takes effect on the next request
    return json(200, { loggedOut: true }, { "set-cookie": writeCookie("", 0) });
  }

  if (path === "/me") {
    const { record } = resolveSession(request);
    if (!record) return json(401, { error: "no session" });
    return json(200, { code: record.principal.code, role: record.principal.role });
  }

  // Diagnostic endpoint: only for this lesson, to observe the store. Not present in a real service.
  if (path === "/diagnostics/store") {
    const { id } = resolveSession(request);
    return json(200, {
      recordsInStore: STORE.size,
      cookieIdPrefix: id ? id.slice(0, 12) : null,
      cookieIdInStore: id ? STORE.has(id) : null,
    });
  }
  json(404, { error: "no such resource" });
}).listen(PORT, "127.0.0.1", () => console.log(`session 127.0.0.1:${PORT} secure=${SECURE} fixed=${FIXED}`));
```

The session identifier is generated from 32 bytes of random data. This value's
unpredictability is not negotiable, because it is the record's only protection: an
identifier derived from a counter, a user code, or a timestamp opens the record itself
to everyone.

#### Cookie Attributes

The session identifier is carried by a cookie, and the cookie's attributes determine
where, when, and by whom the identifier will be sent. The same server's two setups can
be seen side by side in the response header.

```bash
node session.mjs 8521 > /dev/null & LOCAL=$!
node session.mjs 8522 secure > /dev/null & TLS=$!
sleep 1
echo "--- local setup (no TLS): Set-Cookie"
curl -s -D - -o /dev/null -X POST -H "content-type: application/json" \
  -d "{\"username\":\"clara.diaz\",\"password\":\"café-books-1876\"}" \
  http://127.0.0.1:8521/login | grep -i "^set-cookie"
echo "--- TLS setup: Set-Cookie"
curl -s -D - -o /dev/null -X POST -H "content-type: application/json" \
  -d "{\"username\":\"clara.diaz\",\"password\":\"café-books-1876\"}" \
  http://127.0.0.1:8522/login | grep -i "^set-cookie"
kill $LOCAL $TLS
```

```
--- local setup (no TLS): Set-Cookie
set-cookie: loan_session=jMg-ttx_fQin9m8R1-D4KebA_lvmsIwiDS8N1t5mObM; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800
--- TLS setup: Set-Cookie
set-cookie: __Host-loan_session=VQXNQ9l8dULDTTSpGvTJRPM-1t8Zj6em3_OCmZH_FWo; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800
```

The identifier values change on every run; what stays fixed is the attributes.

`HttpOnly` makes the cookie unreadable from a script. Its effect shows up in the
browser: `document.cookie` does not see this cookie. The leak path shown in the
Frontend Quality course's Cross-Site Scripting lesson is closed off for the session
identifier by this attribute. The only thing visible on the server side is that the
attribute was sent; applying it is the client's job.

`Secure` requires the cookie to be sent only over a TLS connection. This is the only
meaningful difference between the two outputs above, and it is not optional for a
setup behind TLS: a cookie without the attribute goes out on the network in the open
for a client that falls back to an unencrypted request. The reason the local setup does
not give the attribute is that the browser does not store a `Secure` cookie from an
unencrypted origin in the first place; this is a separate reason for the development
environment to run with TLS.

`SameSite=Lax` restricts the cookie from being attached to requests initiated from
another site. The server operating on ambient authority — that is, the cookie being
attached regardless of who initiated the request — is the source of cross-site request
forgery; this attribute narrows the surface but is not a sufficient defense on its own.

`Path=/` states which paths the cookie is sent to, and `Max-Age` states how long it is
kept on the client. Under the host-prefix rule, the `__Host-` prefix requires the
cookie to be `Secure`, carry `Path=/`, and specify no `Domain`; it prevents the cookie
from being written by subdomains.

None of these attributes determine the lifetime of the record on the server. `Max-Age`
states when the client will forget the cookie; the server decides when the record
becomes invalid. When the two are confused, the result is a "client deleted it but it
is still valid on the server" situation.

#### Renewing the Identifier at Login

The client may hold a session identifier before login. If the server retains this
identifier at login, a value known before login starts pointing to an authorized
record after login. This is called **session fixation**; how the value got placed on
the client is a separate matter — the server's job is not to inherit the value.

The measurement below compares the identifiers before and after login.

```js
// fixation.mjs — measuring whether the session identifier is renewed at login
// Usage: while node session.mjs 8523 fixed &  and  node session.mjs 8524 &  are running
//        node fixation.mjs 8523   /   node fixation.mjs 8524
const PORT = Number(process.argv[2]);
const BASE = `http://127.0.0.1:${PORT}`;
const COOKIE_NAME = "loan_session";

// A value the client holds before login, determined from outside.
const PRESET = "preset-value-0001";

const login = async (cookieValue) => {
  const response = await fetch(`${BASE}/login`, {
    method: "POST",
    headers: { "content-type": "application/json", cookie: `${COOKIE_NAME}=${cookieValue}` },
    body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
  });
  const issued = (response.headers.get("set-cookie") ?? "").split(";")[0].split("=").slice(1).join("=");
  return { status: response.status, issued };
};

const whoAmI = async (cookieValue) => {
  const response = await fetch(`${BASE}/me`, { headers: { cookie: `${COOKIE_NAME}=${cookieValue}` } });
  return `${response.status} ${await response.text()}`;
};

const { issued } = await login(PRESET);
console.log("value before login      :", PRESET);
console.log("issued after login      :", issued.slice(0, 30) + (issued.length > 30 ? "…" : ""));
console.log("identifier renewed      :", issued !== PRESET);
console.log("/me with old value      :", await whoAmI(PRESET));
console.log("/me with new value      :", await whoAmI(issued));
```

```bash
node session.mjs 8523 fixed > /dev/null & FIXED=$!
node session.mjs 8524 > /dev/null & CORRECT=$!
sleep 1
echo "— identifier not renewed at login (wrong setup) —"
node fixation.mjs 8523
echo "— identifier renewed at login (correct setup) —"
node fixation.mjs 8524
kill $FIXED $CORRECT
```

```
— identifier not renewed at login (wrong setup) —
value before login      : preset-value-0001
issued after login      : preset-value-0001
identifier renewed      : false
/me with old value      : 200 {"code":"U-1001","role":"member"}
/me with new value      : 200 {"code":"U-1001","role":"member"}
— identifier renewed at login (correct setup) —
value before login      : preset-value-0001
issued after login      : 9edgXOW_qIMFrGSBQWaWbnIlI3uGmh…
identifier renewed      : true
/me with old value      : 401 {"error":"no session"}
/me with new value      : 200 {"code":"U-1001","role":"member"}
```

In the first setup, the value known before login returns member U-1001's record after
login. In the second setup, the same value gets a 401; the record has moved to a new,
unpredictable identifier, and the old one has been deleted from the store. The rule is
one sentence: at every point where the privilege level changes — login, account
switch, second-factor verification, privilege escalation — the session identifier is
renewed.

#### The Effect of Logout and Lifetime Limits

Because the session record lives on the server, deleting it produces a direct
consequence. If there is no record, the next request gets a 401; there is no waiting
period in between.

A record can also drop on its own. The **idle timeout** bounds the time elapsed since
the last request; every request resets the counter. The **absolute lifetime** bounds
the total time since the record was opened and is not extended by requests. In the
measurement below, the idle timeout is pulled down to 1.5 seconds.

```js
// lifetime.mjs — when logout and the idle timeout take effect
// Usage: while IDLE_MS=1500 node session.mjs 8525 &  is running,  node lifetime.mjs 8525
const PORT = Number(process.argv[2] ?? 8525);
const BASE = `http://127.0.0.1:${PORT}`;
const COOKIE_NAME = "loan_session";

const login = await fetch(`${BASE}/login`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
});
const cookie = (login.headers.get("set-cookie") ?? "").split(";")[0];
const headers = { cookie };
const status = async (path) => (await fetch(BASE + path, { headers })).status;
const store = async () => await (await fetch(`${BASE}/diagnostics/store`, { headers })).json();

console.log("after login /me         :", await status("/me"), "| store:", (await store()).recordsInStore);
await fetch(`${BASE}/logout`, { method: "POST", headers });
console.log("after logout /me        :", await status("/me"), "| store:", (await store()).recordsInStore);

// Second session: idle timeout
const login2 = await fetch(`${BASE}/login`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
});
const headers2 = { cookie: (login2.headers.get("set-cookie") ?? "").split(";")[0] };
const status2 = async () => (await fetch(`${BASE}/me`, { headers: headers2 })).status;
console.log("new session /me         :", await status2());
await new Promise((c) => setTimeout(c, 800));
console.log("after 0.8s /me          :", await status2(), "(limit 1.5s; every request resets the counter)");
await new Promise((c) => setTimeout(c, 1800));
console.log("after 1.8s idle /me     :", await status2());
```

```bash
IDLE_MS=1500 node session.mjs 8525 > /dev/null & SERVER=$!
sleep 1
node lifetime.mjs 8525
kill $SERVER
```

```
after login /me         : 200 | store: 1
after logout /me        : 401 | store: 0
new session /me         : 200
after 0.8s /me          : 200 (limit 1.5s; every request resets the counter)
after 1.8s idle /me     : 401
```

After the logout request, no record was left in the store, and a request with the same
cookie got a 401. This is the defining property of a session-based setup and the
comparison point for the next lesson.

#### The Cost of the Store

Because the store lives on the server, it has a cost. Every request requires reading
the store; in a single process this is a `Map` access, and across multiple processes
it is a network call to a shared store. When the application scales horizontally, the
record must live somewhere every process can reach; a store each process keeps in its
own memory leaves a user without a session if their request lands on a different
process. Solving this by always routing the request to the same process drops every
session tied to that process when the process goes down.

In return, the store gives a measurable capability: how many open sessions an account
has, when a session was last used, and which session can be closed with a single
request are all information the server knows.

#### Summary

- A session is a record opened on the server at login; the client carries only a
  random session identifier pointing to that record, and the password no longer goes
  out over the network.
- Cookie attributes determine where and when the identifier is sent: `HttpOnly`
  restricts reading from a script, `Secure` restricts unencrypted transmission,
  `SameSite` restricts attachment to requests initiated from another site; the
  `__Host-` prefix makes these rules mandatory.
- `Max-Age` states how long the client will keep the cookie; the server decides the
  record's validity, and the two are separate lifetimes.
- The session identifier is renewed at every point where the privilege level changes;
  in a setup that does not renew it, a value known before login points to an
  authorized record after login.
- Because the record lives on the server, logout is effective immediately; the idle
  timeout is measured from the last request, and the absolute lifetime from when the
  record was opened.
- The store's cost is a read on every request and a shared-store requirement under
  horizontal scaling; in return, open sessions can be counted and closed individually.

#### Next Step

The store's cost gives rise to the desire to eliminate it. Instead of turning an
identifier into a record, the server can write the principal itself **into** the value
the request carries, and sign that value to make sure it was not altered. In such a
setup, verification comes down to a local computation, the shared-store requirement
disappears, and services can recognize the same identity without connecting to each
other's session store. The next lesson builds this setup and measures its cost: if
there is no record on the server, what does logout delete, and how long does a value
that is never deleted stay valid?

### Token-Based Authentication

Source: https://academia.sh/en/courses/authentication-and-authorization/token-based-authentication

Writing the principal into the signed value the request carries, and verification coming down to a local computation: the distinction between opaque and self-contained tokens, the bearer scheme, what the signature guarantees, and measuring that logout is not immediately effective.

The session store carried a cost: every request required reading the store, and when
the application scaled horizontally, the store had to live somewhere every process
could reach. The source of the cost was that the value the request carried was
**meaningless**; the work of making sense of the identity always sat with the server.

This lesson builds the reverse. Who the principal is and what they are authorized to
do is written into the value the request carries, and that value is signed to make
sure it was not altered. During verification, the server looks at no record — it only
computes the signature. The gain is measurable, and so is the loss: if there is no
record, what does logout delete?

#### The Two Kinds of Token

An **opaque token** is a string whose carried value itself contains no meaning. The
previous lesson's session identifier is of this kind: the value points to a record and
says nothing without that record. A service that receives the token learns whether it
is valid and who the principal behind it is by asking the service that issued it —
this is called **token introspection**.

A **self-contained token** carries the information about the principal within itself
and is protected by a signature. If the verifying party can compute the signature, it
asks nowhere. This kind's gain is that verification is local: the loan service, the
catalog, and the fine service can each verify the same token separately, and no
session store shared between them is required.

The distinction is a trade-off. With an opaque token, the validity decision stays in
the issuer's hands and can be changed at any moment; in exchange, every verification
requires a query. With a self-contained token, verification is cheap; in exchange, the
decision is frozen at the moment the token is issued.

#### Issuing and Verifying a Signed Token

The server below encodes the body with base64url and appends an HMAC-SHA256 signature
next to it. This format is specific to this lesson; the next lesson builds the
standard format that does the same job.

```js
// token.mjs — stateless authentication with a self-contained, signed token
// Usage: node token.mjs <port> [revoke]
//   revoke: at logout, the token identifier is written to the revocation list
// Env: LIFETIME_MS (token lifetime, default 6000)
import { createServer } from "node:http";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8531);
const HAS_REVOCATION_LIST = process.argv.includes("revoke");
const LIFETIME_MS = Number(process.env.LIFETIME_MS ?? 6000);

const KEY = randomBytes(32);                    // signing key; stays on the server
const ACCOUNTS = new Map([["clara.diaz", { password: "café-books-1876", code: "U-1001", role: "member" }]]);

// Revocation list: token identifier -> how long it is kept
const REVOKED = new Map();
let verificationCount = 0, storeReadCount = 0;

const b64 = (v) => Buffer.from(v).toString("base64url");
const sign = (body) => createHmac("sha256", KEY).update(body).digest("base64url");

const issue = (principal) => {
  const body = b64(JSON.stringify({ ...principal, jti: randomBytes(9).toString("base64url"), exp: Date.now() + LIFETIME_MS }));
  return `${body}.${sign(body)}`;
};

const verify = (token) => {
  verificationCount++;
  const [body, signature] = (token ?? "").split(".");
  if (!body || !signature) return { error: "format" };

  const expected = Buffer.from(sign(body), "utf8");
  const given = Buffer.from(signature, "utf8");
  if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { error: "signature" };

  const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
  if (Date.now() >= claims.exp) return { error: "expired" };

  if (HAS_REVOCATION_LIST) {
    storeReadCount++;                              // the one point where statelessness is given up
    if (REVOKED.has(claims.jti)) return { error: "revoked" };
  }
  return { claims };
};

const readBody = (request) =>
  new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(v)); });

createServer(async (request, response) => {
  response.sendDate = false;
  const path = request.url.split("?")[0];
  const json = (code, body, headers = {}) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8", ...headers });
    response.end(JSON.stringify(body));
  };

  if (request.method === "POST" && path === "/login") {
    const { username, password } = JSON.parse((await readBody(request)) || "{}");
    const account = ACCOUNTS.get(username ?? "");
    if (!account || password !== account.password) return json(401, { error: "could not authenticate" });
    return json(200, { token: issue({ code: account.code, role: account.role }) });
  }

  const bearer = (request.headers.authorization ?? "").startsWith("Bearer ")
    ? request.headers.authorization.slice(7) : null;

  if (request.method === "POST" && path === "/logout") {
    const result = verify(bearer);
    if (result.claims && HAS_REVOCATION_LIST) REVOKED.set(result.claims.jti, result.claims.exp);
    return json(200, { loggedOut: true, hasRevocationList: HAS_REVOCATION_LIST });
  }

  if (path === "/me") {
    if (!bearer) {
      return json(401, { error: "no token" }, { "www-authenticate": 'Bearer realm="loans"' });
    }
    const result = verify(bearer);
    if (result.error) {
      return json(401, { error: result.error },
        { "www-authenticate": `Bearer realm="loans", error="invalid_token", error_description="${result.error}"` });
    }
    return json(200, { code: result.claims.code, role: result.claims.role });
  }

  if (path === "/diagnostics") {
    // Expired records are dropped from the list: the list holds not every token,
    // only the ones whose lifetime has not run out.
    for (const [jti, exp] of REVOKED) if (Date.now() >= exp) REVOKED.delete(jti);
    return json(200, { verificationCount, storeReadCount, revocationListLength: REVOKED.size });
  }
  json(404, { error: "no such resource" });
}).listen(PORT, "127.0.0.1", () => console.log(`token 127.0.0.1:${PORT} revoke=${HAS_REVOCATION_LIST}`));
```

The token is sent in the `Authorization` header with the **bearer scheme**. The
scheme's name is meaningful: whoever carries the token owns it. A party that gets hold
of the header needs no password besides it; this creates the same transport
requirement as the basic scheme's situation and again rules out an unencrypted
connection.

The rejection response states what the problem is in the `WWW-Authenticate` header.

```bash
LIFETIME_MS=60000 node token.mjs 8533 > /dev/null & SERVER=$!
sleep 1
curl -s -D - -o /dev/null -H "Authorization: Bearer broken.value" http://127.0.0.1:8533/me | grep -i "^www-authenticate"
kill $SERVER
```

```
www-authenticate: Bearer realm="loans", error="invalid_token", error_description="signature"
```

#### The Readability of the Body

The signature guarantees that the body is in the state it left the server in. It does
**not** hide the body. The encoding is base64url, and as seen in the previous lesson,
decoding it requires no key.

```js
// payload.mjs — the readability of the body carried by the token, and what the signature verifies
// Usage: while LIFETIME_MS=60000 node token.mjs 8533 &  is running,  node payload.mjs 8533
const PORT = Number(process.argv[2] ?? 8533);
const BASE = `http://127.0.0.1:${PORT}`;

const login = await fetch(`${BASE}/login`, {
  method: "POST", headers: { "content-type": "application/json" },
  body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
});
const { token } = await login.json();
const [body, signature] = token.split(".");

console.log("body      (base64url):", body);
console.log("signature (base64url):", signature);
console.log("body decoded:", Buffer.from(body, "base64url").toString("utf8"));

const ask = async (value) =>
  (await fetch(`${BASE}/me`, { headers: { authorization: `Bearer ${value}` } })).status;

// Body is altered, signature is left as is: a body that makes the role "staff"
const altered = Buffer.from(JSON.stringify({ code: "U-1001", role: "staff", jti: "x", exp: Date.now() + 60000 }))
  .toString("base64url");

console.log("unchanged        :", await ask(token));
console.log("body altered     :", await ask(`${altered}.${signature}`));
console.log("signature dropped:", await ask(body));
```

```bash
LIFETIME_MS=60000 node token.mjs 8533 > /dev/null & SERVER=$!
sleep 1
node payload.mjs 8533
kill $SERVER
```

```
body      (base64url): eyJjb2RlIjoiVS0xMDAxIiwicm9sZSI6Im1lbWJlciIsImp0aSI6IlJpX0xFNVg1TFZqZyIsImV4cCI6MTc4NzA3ODYwNjA4Mn0
signature (base64url): UJSKObr4iiXUdLW-RErFtEFhNJbDr9RiCK4_lbnwNEU
body decoded: {"code":"U-1001","role":"member","jti":"Ri_LE5X5LVjg","exp":1787078606082}
unchanged        : 200
body altered     : 401
signature dropped: 401
```

The token identifier and timestamp change on every run. What the three lines say must
be read separately. The body is readable: every field inside it is open to anyone who
gets hold of the token. This is why a password, an ID number, an internal table key,
or any other secret is never written into the body. Altering the body, on the other
hand, produces no result: a body that makes the role `staff` has no chance of matching
the old signature, and the server returns a 401. Dropping the signature leads to the
same outcome — a body without a signature cannot pass the format check.

In short, the signature provides **integrity**, not **confidentiality**. Confusing the
two is the source of setups that write secrets into the body.

#### The Revocation Problem

Because verification looks at no record, there is no place where the server can say
"this token is no longer valid." A logout request can delete the token from the
client, but if another copy exists, that copy keeps working. The duration can be
measured: the token lifetime is pulled down to 6 seconds, `/me` is asked continuously
after logout, and the time elapsed until the first failure is recorded.

```js
// revocation-measurement.mjs — measures how many seconds the token stays valid after logout
// Usage: LIFETIME_MS=6000 node token.mjs 8531 &         (no revocation list)
//        LIFETIME_MS=6000 node token.mjs 8532 revoke &  (with revocation list)
//        node revocation-measurement.mjs 8531   /   node revocation-measurement.mjs 8532
const PORT = Number(process.argv[2]);
const BASE = `http://127.0.0.1:${PORT}`;

const login = await fetch(`${BASE}/login`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
});
const { token } = await login.json();
const headers = { authorization: `Bearer ${token}` };
const status = async () => (await fetch(`${BASE}/me`, { headers })).status;

console.log("before logout /me :", await status());
await fetch(`${BASE}/logout`, { method: "POST", headers });

const logoutTime = Date.now();
let stayedValid = 0;
for (;;) {
  const code = await status();
  if (code !== 200) break;
  stayedValid = Date.now() - logoutTime;
  await new Promise((c) => setTimeout(c, 250));
}
console.log("time it stayed valid after logout:", (stayedValid / 1000).toFixed(1), "s");
console.log("diagnostics:", await (await fetch(`${BASE}/diagnostics`)).json());
```

```bash
LIFETIME_MS=6000 node token.mjs 8531 > /dev/null & UNLISTED=$!
LIFETIME_MS=6000 node token.mjs 8532 revoke > /dev/null & LISTED=$!
sleep 1
echo "— no revocation list —"
node revocation-measurement.mjs 8531
echo "— with revocation list —"
node revocation-measurement.mjs 8532
kill $UNLISTED $LISTED
```

```
— no revocation list —
before logout /me : 200
time it stayed valid after logout: 5.8 s
diagnostics: { verificationCount: 27, storeReadCount: 0, revocationListLength: 0 }
— with revocation list —
before logout /me : 200
time it stayed valid after logout: 0.0 s
diagnostics: { verificationCount: 3, storeReadCount: 3, revocationListLength: 1 }
```

The measured durations move by a few percent; what matters is the order of magnitude.
In the setup without a list, the logout request's server-side counterpart is empty:
the token keeps working until the end of its lifetime. At a six-second lifetime this
is six seconds; at an eight-hour lifetime it is eight hours. The previous lesson's
session store gave zero for the same measurement, because the decision was deleting a
record.

The same output also shows where the gain comes from. In the setup without a list,
`storeReadCount` is zero: twenty-seven verifications were done and none of them asked
anywhere. In the setup with a list, `revocationListLength` rose to one; the single
record written at logout stays there until its lifetime runs out.

#### The Cost of the Revocation List

The second setup writes the token identifier to a **revocation list** at logout and
checks that list on every verification. The measurement gives zero seconds: the
decision is effective immediately. But `storeReadCount` is now equal to the
verification count — statelessness is given up at this point.

Still, a quantitative difference remains between the two setups. The session store
holds **every open session** and a record stays until the user logs out or the
lifetime runs out. The revocation list holds only tokens that are **revoked and not
yet expired**; a record whose lifetime runs out drops from the list, because the
expiration check would reject it anyway. This list shrinks as the token lifetime
shortens.

This leads to a practical rule: for a stateless token, the solution to revocation is
not growing the list but shortening the lifetime. When the lifetime shortens, the
revocation window shortens with it. But a short lifetime forces the user to log in
again every minute; the solution to this dilemma is a lesson of its own.

#### Summary

- An opaque token points to a record and its verification requires a query; a
  self-contained token carries the information within itself and its verification is
  local.
- A self-contained token is signed; the signature guarantees the body has not changed
  but does not hide the body, so no secret is written into the body.
- The token is sent with the bearer scheme; the carrier being considered the owner
  again rules out an unencrypted connection, and the reason for rejection is stated
  in the `WWW-Authenticate` header.
- Because there is no record on the server, logout is not effective immediately: in
  the measurement, the token kept working until its lifetime ran out.
- A revocation list makes the decision effective immediately but ties every
  verification to a read; because the list holds only unexpired revocations, it is
  smaller than a session store.
- In a stateless setup, the variable that determines the revocation window is the
  token lifetime; a short lifetime narrows the window, and that is where the real
  solution lies.

#### Next Step

The token format used in this lesson was specific to this lesson: body, dot,
signature. A widespread standard exists that does the same job, and it fixes the
fields' names, how the signature algorithm is stated, and which unit the duration is
written in. Being a standard means verification is standardized too — and which steps
the verifier must not skip matters more than the format itself. The next lesson builds
this format in detail, generates and verifies the signature by hand, and shows why the
verifier collapses at the point where it trusts what the token says about itself.

### JSON Web Tokens

Source: https://academia.sh/en/courses/authentication-and-authorization/json-web-tokens

The signed token's standard format: the three-part compact serialization, what the registered claims mean, the steps a verifier must not skip, asking the configuration rather than the token for the algorithm, the clock skew allowance, and the signing key's length.

The previous lesson's token format was specific to that lesson: body, dot, signature.
A widespread standard that does the same job exists — the **JSON Web Token** (JWT) —
and it fixes the fields' names, how the signature algorithm is stated, and which unit
the duration is written in. The real consequence of the format being standardized is
that verification is standardized too: which questions must be asked before accepting
a token is also defined.

This lesson issues the token by hand and verifies it by hand. Two verifiers are
written: one takes the algorithm it expects from its own configuration, the other
looks at what the token's header states. When the decisions the two give for the same
tokens are placed side by side, which verification step is indispensable becomes
measurable.

#### Three Parts

A token consists of three base64url parts separated by dots: header, body, and
signature. The signature is computed over the **encoded forms** of the first two
parts, not over the decoded JSON. This distinction matters, because the same JSON
object has different encodings with different whitespace layouts, and the signature
binds the exact bytes the token is carried in.

This three-part representation is called **compact serialization**; it is the form
defined by the JWS standard for signed data, one that can be carried in a header, a
query string, or a body.

```js
// jwt.mjs — issuing the JWS compact serialization and two separate verifiers
import { createHmac, timingSafeEqual } from "node:crypto";

export const b64 = (v) => Buffer.from(v).toString("base64url");
export const b64Decode = (v) => Buffer.from(v, "base64url").toString("utf8");

const HMAC = { HS256: "sha256", HS384: "sha384", HS512: "sha512" };

const sign = (alg, data, key) =>
  alg === "none" ? "" : createHmac(HMAC[alg], key).update(data).digest("base64url");

// Token issuance: header and body are encoded with base64url, the signature is computed over both.
export const issue = ({ alg = "HS256", kid, claims, key }) => {
  const header = b64(JSON.stringify(kid ? { alg, typ: "JWT", kid } : { alg, typ: "JWT" }));
  const body = b64(JSON.stringify(claims));
  return `${header}.${body}.${sign(alg, `${header}.${body}`, key)}`;
};

const isEqual = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

// CORRECT VERIFIER: the caller fixes the expected algorithm; the header is read only for kid.
export const verify = (token, { key, expectedAlg, issuer, audience, clockSkewSec = 0 }) => {
  const parts = (token ?? "").split(".");
  if (parts.length !== 3) return { error: "format" };
  const [headerB64, bodyB64, signature] = parts;

  let header, claims;
  try {
    header = JSON.parse(b64Decode(headerB64));
    claims = JSON.parse(b64Decode(bodyB64));
  } catch { return { error: "could not be decoded" }; }

  // 1. The algorithm comes from the verifier's configuration, not from what the token states.
  if (header.alg !== expectedAlg) return { error: "alg" };

  // 2. The signature is recomputed with the expected algorithm.
  if (!isEqual(signature, sign(expectedAlg, `${headerB64}.${bodyB64}`, key))) return { error: "signature" };

  // 3. Time claims (in seconds), with the clock skew allowance.
  const now = Math.floor(Date.now() / 1000);
  if (typeof claims.exp !== "number") return { error: "missing exp" };
  if (now >= claims.exp + clockSkewSec) return { error: "expired" };
  if (typeof claims.nbf === "number" && now < claims.nbf - clockSkewSec) return { error: "not yet valid" };

  // 4. Issuer and audience.
  if (claims.iss !== issuer) return { error: "issuer" };
  const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
  if (!aud.includes(audience)) return { error: "audience" };

  return { claims };
};

// WRONG VERIFIER: reads the algorithm from the token's header. Stands as the counter-example.
export const verifyTrustingHeader = (token, { key }) => {
  const [headerB64, bodyB64, signature] = (token ?? "").split(".");
  let header, claims;
  try {
    header = JSON.parse(b64Decode(headerB64));
    claims = JSON.parse(b64Decode(bodyB64));
  } catch { return { error: "could not be decoded" }; }
  if (!isEqual(signature ?? "", sign(header.alg, `${headerB64}.${bodyB64}`, key))) return { error: "signature" };
  return { claims };
};
```

The issued token's parts can be read directly.

```js
// structure.mjs — the token's three parts and reading the claims
// Usage: node structure.mjs   (in the same directory as jwt.mjs)
import { issue, b64Decode } from "./jwt.mjs";

const KEY = Buffer.from("this-key-is-used-only-in-this-lesson-32b", "utf8");
const now = 1785000000;                     // example fixed timestamp (seconds)

const token = issue({
  alg: "HS256",
  kid: "2026-01",
  key: KEY,
  claims: {
    iss: "https://auth.library.example",    // issuer
    sub: "U-1001",                          // subject: the principal the token describes
    aud: "loan-service",                    // audience: the service expected to accept the token
    iat: now,                               // issued-at moment
    nbf: now,                               // not valid before this moment
    exp: now + 900,                         // 15 minutes
    jti: "b-000001",                        // token identifier
    role: "member",                         // service-specific claim
  },
});

const [header, body, signature] = token.split(".");
console.log("token length      :", token.length, "characters");
console.log("header  :", b64Decode(header));
console.log("body    :", b64Decode(body));
console.log("signature:", signature, `(${Buffer.from(signature, "base64url").length} bytes)`);
console.log("data the signature is computed over:", `${header.slice(0, 12)}….${body.slice(0, 12)}…`);
```

```
token length      : 314 characters
header  : {"alg":"HS256","typ":"JWT","kid":"2026-01"}
body    : {"iss":"https://auth.library.example","sub":"U-1001","aud":"loan-service","iat":1785000000,"nbf":1785000000,"exp":1785000900,"jti":"b-000001","role":"member"}
signature: DHHZh78Z1uVy_bifPm7cVUSd51SucLjXZ2eUbAxwaoo (32 bytes)
data the signature is computed over: eyJhbGciOiJI….eyJpc3MiOiJo…
```

The token is 314 characters, and a header of this length is carried on every request.
Every claim added to the body grows this number; a token is not a data carrier, it is
a proof of identity.

#### Registered Claims

The fields in the body are called **claims**. Some of them are named in the standard,
and their meaning is fixed.

- `iss` — the issuer that produced the token. The verifier keeps the issuer it expects
  in its own configuration and rejects a mismatch.
- `sub` — the principal the token describes. In the loan service, this is a member or
  staff code.
- `aud` — the audience: the recipient the token is expected to be accepted by. It can
  be a single string or an array of strings.
- `exp` — the moment validity ends; `nbf` — the moment validity begins; `iat` — the
  moment of issuance. All three are in **seconds** since 1970. Confusing this with
  JavaScript's millisecond-based timestamp produces a token a thousand times longer-
  or shorter-lived than intended.
- `jti` — the token identifier. The revocation list and reuse detection rest on this
  field.

The `aud` claim is the field most often skipped in verification, and skipping it
produces the most concrete consequence. A token issued for the loan service also
passes at the fine service when `aud` is not checked. If two services trust the same
issuer, this field is the only boundary between them.

Claims that have no place in the standard can also be written — like `role` above. To
keep these from colliding, a namespace must be reserved for them; and the rule
established in the previous lesson applies here too: because the body is readable, no
secret is written into it.

#### The Decision on Eight Trials

The script below asks two verifiers about eight different tokens. All of the tokens
are trial values this file produces itself.

```js
// verification-matrix.mjs — the decision eight trial tokens get from two verifiers
// Usage: node verification-matrix.mjs   (in the same directory as jwt.mjs)
import { issue, verify, verifyTrustingHeader, b64 } from "./jwt.mjs";

const KEY = Buffer.from("this-key-is-used-only-in-this-lesson-32b", "utf8");
const OTHER_KEY = Buffer.from("this-key-belongs-to-another-service-32byt", "utf8");
const now = Math.floor(Date.now() / 1000);

const base = {
  iss: "https://auth.library.example", sub: "U-1001", aud: "loan-service",
  iat: now, nbf: now, exp: now + 900, jti: "b-000001", role: "member",
};
const make = (change, alg = "HS256", key = KEY) => issue({ alg, key, claims: { ...base, ...change } });

// Token whose body is altered: the signature is left as is.
const sound = make({});
const [h, , s] = sound.split(".");
const bodyAltered = `${h}.${b64(JSON.stringify({ ...base, role: "staff" }))}.${s}`;

const TRIALS = [
  ["valid", sound],
  ["expired", make({ exp: now - 30 })],
  ["not yet valid", make({ nbf: now + 60 })],
  ["audience is another service", make({ aud: "fine-service" })],
  ["issuer is different", make({ iss: "https://other.example" })],
  ["body altered", bodyAltered],
  ["header alg: none", make({}, "none", KEY)],
  ["signed with another key", make({}, "HS256", OTHER_KEY)],
];

const SETTINGS = {
  key: KEY, expectedAlg: "HS256",
  issuer: "https://auth.library.example", audience: "loan-service",
};

const label = (result) => (result.error ? `reject (${result.error})` : `accept (role=${result.claims.role})`);

console.log("trial".padEnd(30) + "fixes the algorithm".padEnd(26) + "trusts the header");
for (const [name, token] of TRIALS) {
  console.log(name.padEnd(30) + label(verify(token, SETTINGS)).padEnd(26) + label(verifyTrustingHeader(token, SETTINGS)));
}
```

```
trial                         fixes the algorithm       trusts the header
valid                         accept (role=member)      accept (role=member)
expired                       reject (expired)          accept (role=member)
not yet valid                 reject (not yet valid)    accept (role=member)
audience is another service   reject (audience)         accept (role=member)
issuer is different           reject (issuer)           accept (role=member)
body altered                  reject (signature)        reject (signature)
header alg: none              reject (alg)              accept (role=member)
signed with another key       reject (signature)        reject (signature)
```

The right-hand column shows a verifier that checks only the signature, and it accepts
six of the eight trials. Expired tokens, not-yet-valid tokens, tokens issued for
another service, and tokens from another issuer all pass, because signature
verification asks none of these questions. The signature says "this token was not
altered"; it does not say "this token came to you, now, from this issuer."
Verification **starts** with the signature but does not end with it.

The seventh row produces a separate rule. The `alg` field sits in the token's header,
meaning it is a value the token's issuer wrote. If the verifier reads this value and
acts on it, the token itself has decided which check gets applied. The right-hand
verifier accepts a token that states `none`: the signature is empty, the computed
signature is also empty, and the comparison passes. The left-hand verifier rejects the
same token at the very first step, because the algorithm it expects is written in its
own configuration.

The rule is this: the verifier takes the algorithm it expects **from its own
configuration** and reads the header's `alg` field only to detect a mismatch. The
header's `kid` field is separate from this: `kid` states which key to use and selects
among the keys the verifier **recognizes**; it cannot point to a key the verifier does
not recognize.

#### The Clock Skew Allowance

Time claims depend on two separate machines' clocks, and clocks are never perfectly
synchronized. A strict verifier can reject a token freshly issued by an issuer whose
clock runs a few seconds ahead as "not yet valid." This is why verifiers grant a small
**clock skew allowance**.

```js
// clock-skew.mjs — the effect of the clock skew allowance on the verification decision
// Usage: node clock-skew.mjs   (in the same directory as jwt.mjs)
import { issue, verify } from "./jwt.mjs";

const KEY = Buffer.from("this-key-is-used-only-in-this-lesson-32b", "utf8");
const now = Math.floor(Date.now() / 1000);
const SETTINGS = {
  key: KEY, expectedAlg: "HS256",
  issuer: "https://auth.library.example", audience: "loan-service",
};
const claims = (change) => ({
  iss: "https://auth.library.example", sub: "U-1001", aud: "loan-service",
  iat: now, exp: now + 900, ...change,
});

const ask = (token, clockSkewSec) => {
  const s = verify(token, { ...SETTINGS, clockSkewSec });
  return s.error ? `reject (${s.error})` : "accept";
};

const expired = issue({ key: KEY, claims: claims({ exp: now - 2 }) });
const notYetValid = issue({ key: KEY, claims: claims({ nbf: now + 2 }) });

console.log("skew".padEnd(8) + "expired 2s ago".padEnd(28) + "valid in 2s");
for (const skew of [0, 5, 60]) {
  console.log(`${skew}s`.padEnd(8) + ask(expired, skew).padEnd(28) + ask(notYetValid, skew));
}
```

```
skew    expired 2s ago              valid in 2s
0s      reject (expired)            reject (not yet valid)
5s      accept                      accept
60s     accept                      accept
```

The allowance works in both directions and extends the token's effective lifetime. A
five-second allowance makes a fifteen-minute token valid for fifteen minutes and five
seconds; a sixty-second allowance starts to erode the point of setting up a
short-lived token. The right measure is the magnitude of the clock synchronization
error: a few seconds is enough, and minutes mean the allowance is being used to paper
over a configuration fault.

#### The Key's Length and Ownership

In HMAC signing, the key is a secret, and anyone who knows it can issue a valid token.
This is why the signer and the verifier share the same secret. The key's length does
not affect whether verification works; it only determines resilience.

```js
// key.mjs — the HMAC key's length does not block verification, but it does determine resilience
import { createHmac } from "node:crypto";

for (const key of ["1234", "library", "this-key-is-used-only-in-this-lesson-32b"]) {
  const bytes = Buffer.from(key, "utf8").length;
  const signature = createHmac("sha256", key).update("header.body").digest("base64url");
  console.log(`${bytes.toString().padStart(2)} bytes key → signature ${signature.slice(0, 16)}… (length ${Buffer.from(signature, "base64url").length} bytes)`);
}
```

```
 4 bytes key → signature 4n5MWf_MAO6KGupa… (length 32 bytes)
 7 bytes key → signature 61aip2hP4tdMtQZM… (length 32 bytes)
40 bytes key → signature Z5HjxrF9MFTs3M4h… (length 32 bytes)
```

All three signatures are 32 bytes, and none of them reveal that the key is weak. A
token issued with a four-byte key passes verification without trouble; the problem is
not in verification, it is that the key can be found by trial. This is why key length
is not a runtime check but a configuration rule: an HMAC key is generated from random
data at least as long as the digest — 32 bytes for SHA-256. A password found in a
dictionary does not satisfy this condition, even if its length matches.

The shared secret's second consequence is a scaling problem. If the loan, catalog, and
fine services all verify the same token, all three know the same secret; a leak from
any one of the three means a leak of the power to issue tokens. Asymmetric signing
separates this: the issuer signs with a private key, verifiers know only the public
key, and a token cannot be issued with the public key. The header's `kid` field gains
its meaning here too — the issuer can use more than one key, and the verifier learns
which key the token was signed with from this field and selects among the keys it
recognizes.

#### Summary

- A JSON web token is three base64url parts separated by dots; the signature is
  computed over the encoded forms of the header and the body.
- Registered claims have fixed meanings: `iss` carries the issuer, `sub` the
  principal, `aud` the audience, `exp`/`nbf`/`iat` times in seconds, `jti` the token
  identifier.
- Signature verification only says the token was not altered; the duration, issuer,
  and audience checks are separate steps, and skipping them causes tokens issued for
  another service or already expired to be accepted.
- The verifier takes the algorithm it expects from its own configuration; a verifier
  that reads the `alg` field and acts on it has left the decision of which check
  applies to the token itself.
- The clock skew allowance extends the token's effective lifetime; a few seconds
  accounts for clock synchronization error, while minutes paper over a configuration
  problem.
- The HMAC key's length does not affect verification, it determines resilience; a
  shared secret gives every verifier the power to issue tokens, while asymmetric
  signing keeps that power with the issuer.

#### Next Step

The previous lesson's measurement showed that for a stateless token, the variable that
determines the revocation window is the lifetime. This lesson tied the lifetime to the
`exp` field, but the dilemma remains: a short lifetime narrows the revocation window,
while forcing the user to log in again frequently. The solution is to use two tokens
instead of one — one short-lived and sent on every request, the other long-lived and
used only for renewal. The next lesson builds this pair, and shows by running it why
the long-lived token must be replaced on every use, and how a second use of the same
token can be detected.

### Refresh Tokens

Source: https://academia.sh/en/courses/authentication-and-authorization/refresh-tokens

Separating the short-lived access token from the long-lived refresh token: storing the refresh token only as a hash, rotating it on every use, and measuring that presenting a used token again results in family revocation.

The previous two lessons left a dilemma. In a stateless token, the lifetime determined
the revocation window: as the lifetime shortened, the window narrowed, but the user
had to log in again frequently. The dilemma is born from the single-token assumption.
When two tokens are used, lifetime and ease of use can be tuned separately.

The **access token** is short-lived and is attached to every request. The **refresh
token** is long-lived, goes to only one endpoint — the refresh endpoint — and gets a
fresh access token in return. This distinction alone provides no gain; the gain comes
from the long-lived one being recorded on the server and replaced on every use.

#### Two Tokens, Two Tasks

The access token goes out on every request, and so it passes through every
intermediate stop — the reverse proxy, the log, the error-tracing record. The refresh
token goes only to the refresh endpoint; the path it takes is short, and it appears
nowhere outside that path.

The second one is stateful. The access token is verified by its signature and no
record is consulted; the refresh token, on the other hand, corresponds to a row on the
server. This is not abandoning the previous lesson's statelessness: the record read
happens on the refresh request, which occurs about once a minute, not on the resource
request, which occurs hundreds of times a second.

Where the token is placed on the client side is a separate decision, addressed in the
Application Architecture course's Authentication in the Browser lesson; this lesson
builds the server side.

#### Storing the Refresh Token

The refresh token is **not stored in plain form** on the server. The stored value is
the token's hash; the token itself lives only on the client. The token consists of two
parts: an id used for lookup and a secret used for verification. The id part makes it
possible to reach the row directly, without trying every row one by one.

```js
// storage.mjs — the form the refresh token takes when it lives on the server
// Usage: node storage.mjs
import { randomBytes, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE refresh (id TEXT PRIMARY KEY, family TEXT NOT NULL, hash TEXT NOT NULL,
                                issuedAt INTEGER NOT NULL, usedAt INTEGER)`);

const hash = (secret) => createHash("sha256").update(secret).digest("base64url");

const id = randomBytes(9).toString("base64url");
const secret = randomBytes(32).toString("base64url");
const givenToClient = `${id}.${secret}`;
db.prepare("INSERT INTO refresh (id, family, hash, issuedAt) VALUES (?, ?, ?, ?)")
  .run(id, "A-1", hash(secret), 1785000000000);

const row = db.prepare("SELECT * FROM refresh WHERE id = ?").get(id);
console.log("given to client   :", givenToClient);
console.log("row in the table  :", JSON.stringify(row));
console.log("secret in the row :", JSON.stringify(row).includes(secret));
console.log("presented value verifiable:", hash(secret) === row.hash);
```

```
given to client   : TTmjUYLeJBWW.KceoD5WLvBFMGKp_252da785lGs2nMAvS7fxpUWa3Z8
row in the table  : {"id":"TTmjUYLeJBWW","family":"A-1","hash":"Y-axdik0-rxrqG-L1fsDqyNx9DJ6-qQlylepGEhKsys","issuedAt":1785000000000,"usedAt":null}
secret in the row : false
presented value verifiable: true
```

The id and secret change on every run. The row holds no secret; yet the value the
client presents can still be verified. Reading the database does not yield valid
refresh tokens.

The single-pass digest used here is not sufficient for storing passwords. The
difference lies in the predictability of the input: the secret is generated from 32
bytes of random data, and finding it by trial is not a realistic prospect. Because this
condition does not hold for passwords, a separate method is required; that topic is
addressed on its own.

#### The Refresh Endpoint

The server below issues both tokens, builds the refresh endpoint, and can also produce
the without-rotation setup with a flag. Refresh tokens belong to a **family**: the
family opened at one login covers every refresh token descended from that login.

```js
// refresh.mjs — short-lived access token + long-lived refresh token
// Usage: node refresh.mjs <port> [without-rotation]
//   without-rotation: the same refresh token stays valid across refreshes (counter-example)
// Env: ACCESS_MS (access token lifetime, default 3000)
import { createServer } from "node:http";
import { createHmac, randomBytes, timingSafeEqual, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const PORT = Number(process.argv[2] ?? 8541);
const WITHOUT_ROTATION = process.argv.includes("without-rotation");
const ACCESS_MS = Number(process.env.ACCESS_MS ?? 3000);
const FAMILY_LIFETIME_MS = 30 * 24 * 3_600_000;              // the family's absolute lifetime

const KEY = randomBytes(32);
const ACCOUNTS = new Map([["clara.diaz", { password: "café-books-1876", code: "U-1001", role: "member" }]]);

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE family (code TEXT PRIMARY KEY, member TEXT NOT NULL, opened INTEGER NOT NULL,
                       revoked INTEGER NOT NULL DEFAULT 0, revocationReason TEXT);
  CREATE TABLE refresh (id TEXT PRIMARY KEY, family TEXT NOT NULL, hash TEXT NOT NULL,
                         issuedAt INTEGER NOT NULL, usedAt INTEGER);
`);

// Refresh token: "<id>.<secret>". Only the secret's hash lives on the server.
const hash = (secret) => createHash("sha256").update(secret).digest("base64url");
const issueRefresh = (family) => {
  const id = randomBytes(9).toString("base64url");
  const secret = randomBytes(32).toString("base64url");
  db.prepare("INSERT INTO refresh (id, family, hash, issuedAt) VALUES (?, ?, ?, ?)")
    .run(id, family, hash(secret), Date.now());
  return `${id}.${secret}`;
};

const b64 = (v) => Buffer.from(v).toString("base64url");
const issueAccess = (principal) => {
  const body = b64(JSON.stringify({ ...principal, exp: Date.now() + ACCESS_MS }));
  return `${body}.${createHmac("sha256", KEY).update(body).digest("base64url")}`;
};
const verifyAccess = (token) => {
  const [body, signature] = (token ?? "").split(".");
  if (!body || !signature) return null;
  const expected = createHmac("sha256", KEY).update(body).digest("base64url");
  const a = Buffer.from(expected, "utf8"), b = Buffer.from(signature, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
  return Date.now() < claims.exp ? claims : null;
};

const revokeFamily = (code, reason) =>
  db.prepare("UPDATE family SET revoked = 1, revocationReason = ? WHERE code = ?").run(reason, code);

// Evaluating the refresh token: four separate rejection reasons are distinguished.
const resolveRefresh = (value) => {
  const [id, secret] = (value ?? "").split(".");
  const row = id ? db.prepare("SELECT * FROM refresh WHERE id = ?").get(id) : undefined;
  if (!row) return { error: "unrecognized" };

  const a = Buffer.from(hash(secret ?? ""), "utf8"), b = Buffer.from(row.hash, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return { error: "hash" };

  const family = db.prepare("SELECT * FROM family WHERE code = ?").get(row.family);
  if (family.revoked) return { error: "family revoked", family };
  if (Date.now() - family.opened > FAMILY_LIFETIME_MS) return { error: "family lifetime expired", family };

  // A used token was presented again: the sign that a copy is circulating.
  if (row.usedAt !== null && !WITHOUT_ROTATION) {
    revokeFamily(family.code, "reuse detected");
    return { error: "reuse", family };
  }
  return { row, family };
};

const readBody = (request) =>
  new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(v)); });

createServer(async (request, response) => {
  response.sendDate = false;
  const path = request.url.split("?")[0];
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8" });
    response.end(JSON.stringify(body));
  };

  if (request.method === "POST" && path === "/login") {
    const { username, password } = JSON.parse((await readBody(request)) || "{}");
    const account = ACCOUNTS.get(username ?? "");
    if (!account || password !== account.password) return json(401, { error: "could not authenticate" });
    const family = randomBytes(6).toString("base64url");
    db.prepare("INSERT INTO family (code, member, opened) VALUES (?, ?, ?)").run(family, account.code, Date.now());
    return json(200, { access: issueAccess({ code: account.code, role: account.role }), refresh: issueRefresh(family) });
  }

  if (request.method === "POST" && path === "/refresh") {
    const { refresh } = JSON.parse((await readBody(request)) || "{}");
    const result = resolveRefresh(refresh);
    if (result.error) return json(401, { error: result.error });

    const member = db.prepare("SELECT member FROM family WHERE code = ?").get(result.family.code).member;
    const body = { access: issueAccess({ code: member, role: "member" }) };
    if (WITHOUT_ROTATION) {
      body.refresh = refresh;                       // the same token stays valid
    } else {
      db.prepare("UPDATE refresh SET usedAt = ? WHERE id = ?").run(Date.now(), result.row.id);
      body.refresh = issueRefresh(result.family.code);   // rotation: a new token
    }
    return json(200, body);
  }

  if (path === "/me") {
    const bearer = (request.headers.authorization ?? "").startsWith("Bearer ")
      ? request.headers.authorization.slice(7) : null;
    const claims = verifyAccess(bearer);
    return claims ? json(200, { code: claims.code }) : json(401, { error: "access token invalid" });
  }

  if (path === "/diagnostics") {
    return json(200, {
      families: db.prepare("SELECT code, revoked, revocationReason FROM family").all(),
      refreshCount: db.prepare("SELECT COUNT(*) n FROM refresh").get().n,
      usedCount: db.prepare("SELECT COUNT(*) n FROM refresh WHERE usedAt IS NOT NULL").get().n,
    });
  }
  json(404, { error: "no such resource" });
}).listen(PORT, "127.0.0.1", () => console.log(`refresh 127.0.0.1:${PORT} without-rotation=${WITHOUT_ROTATION}`));
```

#### The Access Token's Lifetime

When the access token's lifetime is pulled down to three seconds, the flow becomes
observable: a 401 is received once the token expires, a fresh token is obtained from
the refresh endpoint, and the request works again.

```js
// access-lifetime.mjs — the access token's lifetime and refreshing it back to full strength
// Usage: while ACCESS_MS=3000 node refresh.mjs 8543 &  is running,  node access-lifetime.mjs 8543
const PORT = Number(process.argv[2] ?? 8543);
const BASE = `http://127.0.0.1:${PORT}`;
const send = async (path, body) =>
  (await fetch(BASE + path, {
    method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
  })).json();
const status = async (access) =>
  (await fetch(`${BASE}/me`, { headers: { authorization: `Bearer ${access}` } })).status;

const login = await send("/login", { username: "clara.diaz", password: "café-books-1876" });
const start = Date.now();
console.log("/me at login             :", await status(login.access));
await new Promise((c) => setTimeout(c, 3200));
console.log("/me after 3.2s           :", await status(login.access),
  `(access lifetime 3s, elapsed ${((Date.now() - start) / 1000).toFixed(1)}s)`);

const fresh = await send("/refresh", { refresh: login.refresh });
console.log("/me after refreshing     :", await status(fresh.access));
console.log("with old access token    :", await status(login.access));
```

```bash
ACCESS_MS=3000 node refresh.mjs 8543 > /dev/null 2>&1 & S=$!
sleep 1.2
node access-lifetime.mjs 8543
kill $S
```

```
/me at login             : 200
/me after 3.2s           : 401 (access lifetime 3s, elapsed 3.2s)
/me after refreshing     : 200
with old access token    : 401
```

The last line matters: refreshing does not make the old access token valid again.
Every access token is bound to its own `exp` value, and once it expires it does not
come back. All refreshing does is issue a new token.

#### Rotated Use

**Rotation** is replacing the refresh token on every use: the server marks the old row
as used and issues a new token. The consequence of this is that every refresh token is
**single-use**.

The measurable gain of being single-use is that a used token, when presented again,
carries a **signal**. A normal client does not do this, because it received a new
token the moment it used the one it had. A second presentation of the same token says
that a copy of that token is circulating.

```js
// rotation.mjs — measuring rotated use and reuse detection
// Usage: ACCESS_MS=3000 node refresh.mjs 8541 &                    (rotation on)
//        ACCESS_MS=3000 node refresh.mjs 8542 without-rotation &   (rotation off)
//        node rotation.mjs 8541   /   node rotation.mjs 8542
const PORT = Number(process.argv[2]);
const BASE = `http://127.0.0.1:${PORT}`;
const send = async (path, body) =>
  (await fetch(BASE + path, {
    method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
  })).json();

const login = await send("/login", { username: "clara.diaz", password: "café-books-1876" });
const firstRefresh = login.refresh;

// Refresh three times; each time use the refresh token currently held.
let held = firstRefresh;
for (let i = 1; i <= 3; i++) {
  const r = await send("/refresh", { refresh: held });
  console.log(`refresh ${i}:`, r.error ? `reject (${r.error})` : "accept",
    "| new refresh token:", !r.error && r.refresh !== held);
  if (!r.error) held = r.refresh;
}

// The first refresh token is presented again (the case where a copy is circulating).
const again = await send("/refresh", { refresh: firstRefresh });
console.log("first token again     :", again.error ? `reject (${again.error})` : "accept");

// Retried with the most recently valid token.
const after = await send("/refresh", { refresh: held });
console.log("with the current token:", after.error ? `reject (${after.error})` : "accept");
console.log("diagnostics:", JSON.stringify(await (await fetch(`${BASE}/diagnostics`)).json()));
```

```bash
ACCESS_MS=3000 node refresh.mjs 8541 > /dev/null 2>&1 & ROTATED=$!
ACCESS_MS=3000 node refresh.mjs 8542 without-rotation > /dev/null 2>&1 & UNROTATED=$!
sleep 1.2
echo "— rotated use —"
node rotation.mjs 8541
echo "— no rotation —"
node rotation.mjs 8542
kill $ROTATED $UNROTATED
```

```
— rotated use —
refresh 1: accept | new refresh token: true
refresh 2: accept | new refresh token: true
refresh 3: accept | new refresh token: true
first token again     : reject (reuse)
with the current token: reject (family revoked)
diagnostics: {"families":[{"code":"KxzE6xW5","revoked":1,"revocationReason":"reuse detected"}],"refreshCount":4,"usedCount":3}
— no rotation —
refresh 1: accept | new refresh token: false
refresh 2: accept | new refresh token: false
refresh 3: accept | new refresh token: false
first token again     : accept
with the current token: accept
diagnostics: {"families":[{"code":"2QSKkxdS","revoked":0,"revocationReason":null}],"refreshCount":1,"usedCount":0}
```

The family codes change on every run. The difference between the two blocks tells the
whole story of what rotation buys on its own.

In the rotated setup, after three refreshes the table has four rows, and three of them
are used. When the first token is presented again, the server **detects** it: the
row's `usedAt` field is filled in. The decision's consequence is not just rejecting
that request — the family is revoked, and as the next line shows, even the **current**
token stops working. The reason is that which side is the copy cannot be known: both
the one presenting the old token again and the one holding the current token belong to
the same family. The safe decision is to drop both and require a new login.

In the without-rotation setup, the same token was used four times, all four were
accepted, and the table has a single row; `usedCount` is zero. There is no trace to
show whether a copy is circulating. A condition the server cannot detect is a
condition the server cannot respond to.

#### False Alarms and the Family's Lifetime

Detection has one flaw: two **concurrent** requests with the same refresh token
produce the same signal. If two tabs of the application, or two requests, get a 401 at
the same moment and both go to refresh with the same token, the second one ends up
presenting a used token, and the family is revoked for nothing. The solution sits on
the client side and is built under the name **single flight** in the Application
Architecture course's Session Renewal lesson: only one refresh request goes out at a
time, and the others wait for its result. On the server side, a small grace window can
also be granted — the same request arriving within a few seconds of a rotation is
answered with the same new token.

The family also has an absolute lifetime. Because rotation issues a new token on every
use, a family in regular use never ends on its own. `FAMILY_LIFETIME_MS` bounds this: a
certain time after the first login, the family closes regardless of how much it is
used, and a new login is required. The family record is also the counterpart of
logout: a logout request revokes the family, so every refresh token descended from
that login drops in a single operation.

The revocation window thus splits into two different durations. For the access token,
the window is its short lifetime — three seconds, fifteen minutes, whatever was
chosen. For the refresh token, the window is zero, because the decision is a row in
the database.

#### Summary

- The access token is short-lived and attached to every request; the refresh token is
  long-lived and goes only to the refresh endpoint.
- The refresh token is stored on the server as a hash; the token consists of an id
  used for lookup and a secret used for verification, and reading the database does
  not yield a valid token.
- Rotation replaces the refresh token on every use and makes it single-use; a used
  token being presented again is a signal that a copy is circulating.
- On detection, the family is revoked; because which side is the copy cannot be
  known, the current token drops too.
- In a without-rotation setup, the same token is used without limit, and no trace is
  left to show reuse.
- Concurrent refreshing can produce a false alarm; single flight on the client and a
  short grace window on the server address it. The family's absolute lifetime and
  revocation at logout determine the session's end.

#### Next Step

Up to this point, every step of authentication has taken place on a single side: the
user gave their password to the loan service, and the loan service issued the token
and verified it itself too. This arrangement does not work when the library's district
branches share a common member registry, or when a member wants to expose their loan
history to a third-party reading app: the password would have to be given to that
application. The next lesson builds the authorization delegation protocol that solves
this problem — the resource owner gives their password only to their own identity
provider, and the application gets a limited authorization in return. The
authorization code flow, the `state` parameter, and the verifier will be run end to
end with your own local servers.

### OAuth 2 Flows

Source: https://academia.sh/en/courses/authentication-and-authorization/oauth-2-flows

Building authorization delegation with four roles: the authorization code flow run end to end with an authorization server, a resource server, and a client; the rules of the PKCE verifier and the state parameter; the code's single use; and the client credentials and device flows.

Up to this point, every step of authentication has taken place on a single side: the
user gave their password to the loan service, and the loan service issued the token
and verified it itself too. This arrangement does not work when a member wants to
expose their loan history to a third-party reading app — the application would have to
be given the password. And an application holding the password would not only read the
loan history; it could do anything the member could do, and access could only be cut
off by changing the password.

**OAuth 2** solves this problem by delegating authorization. The resource owner gives
their credential only to their own authorization server; what the application gets is
a token limited in scope and lifetime.

#### Four Roles

The **resource owner** is the party that grants authorization — here, member U-1001.
The **client** is the application requesting authorization. The **authorization
server** authenticates the resource owner, obtains their consent, and issues the
token. The **resource server** verifies the token and returns the protected data.

In the authorization code flow, the client does not receive the token directly. The
resource owner is redirected to the authorization server, a short-lived
**authorization code** comes back to the client from there, and the client exchanges
this code for a token with a separate request that does not pass through the browser.
This is why the token never appears in the browser's address bar.

#### The Authorization Server

```js
// authorization-server.mjs — authorization code (PKCE), client credentials, and device flows
// Usage: node authorization-server.mjs [port]     (default 8551)
import { createServer } from "node:http";
import { createHmac, createHash, randomBytes, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8551);
const ISSUER = `http://127.0.0.1:${PORT}`;
const KEY = Buffer.from("identity-provider-signing-key-32bytes!!!", "utf8");

// A "public" client cannot store a secret (browser/mobile); a "confidential" client can.
const CLIENTS = new Map([
  ["reading-app", { type: "public", redirectUris: ["http://127.0.0.1:8553/callback"], scopes: ["loan:read"] }],
  ["shelf-terminal", { type: "confidential", secret: "shelf-terminal-secret-1", scopes: ["loan:read", "loan:write"] }],
]);
const CODES = new Map(), DEVICES = new Map();

const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const isEqual = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};
const issueAccess = (sub, client, scopes) => {
  const body = Buffer.from(JSON.stringify({ iss: ISSUER, sub, aud: "loan-service", client_id: client,
    scope: scopes.join(" "), exp: Math.floor(Date.now() / 1000) + 600 })).toString("base64url");
  return `${body}.${createHmac("sha256", KEY).update(body).digest("base64url")}`;
};
const readForm = (request) => new Promise((resolve) => {
  let v = ""; request.on("data", (p) => (v += p));
  request.on("end", () => resolve(Object.fromEntries(new URLSearchParams(v))));
});

createServer(async (request, response) => {
  response.sendDate = false;
  const url = new URL(request.url, ISSUER), s = url.searchParams;
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
    response.end(JSON.stringify(body));
  };

  if (request.method === "GET" && url.pathname === "/authorize") {
    const client = CLIENTS.get(s.get("client_id") ?? ""), redirect = s.get("redirect_uri") ?? "";
    // If the client or redirect address is not recognized, NO REDIRECT HAPPENS; the error stops here.
    if (!client || !(client.redirectUris ?? []).includes(redirect)) {
      return json(400, { error: "invalid_request", detail: "client or redirect address not recognized" });
    }
    const returnWithError = (code) => {
      const u = new URL(redirect);
      u.searchParams.set("error", code);
      if (s.get("state")) u.searchParams.set("state", s.get("state"));
      response.writeHead(302, { location: u.toString() }); response.end();
    };
    if (s.get("response_type") !== "code" || !s.get("state")) return returnWithError("invalid_request");
    if (!s.get("code_challenge") || s.get("code_challenge_method") !== "S256") return returnWithError("invalid_request");
    const requested = (s.get("scope") ?? "").split(" ").filter(Boolean);
    if (!requested.length || !requested.every((k) => client.scopes.includes(k))) return returnWithError("invalid_scope");

    // At this point the resource owner is considered to have authenticated and passed the consent screen.
    const code = randomBytes(24).toString("base64url");
    CODES.set(code, { client_id: s.get("client_id"), redirect_uri: redirect, challenge: s.get("code_challenge"),
      scopes: requested, sub: "U-1001", exp: Date.now() + 60_000, used: false });
    const u = new URL(redirect);
    u.searchParams.set("code", code); u.searchParams.set("state", s.get("state"));
    response.writeHead(302, { location: u.toString() });
    return response.end();
  }

  if (request.method === "POST" && url.pathname === "/device/code") {
    const form = await readForm(request);
    const deviceCode = randomBytes(18).toString("base64url"), userCode = randomBytes(3).toString("hex").toUpperCase();
    DEVICES.set(deviceCode, { client_id: form.client_id, userCode, approved: false, scopes: (form.scope ?? "").split(" ") });
    return json(200, { device_code: deviceCode, user_code: userCode,
      verification_uri: `${ISSUER}/device`, expires_in: 600, interval: 1 });
  }
  if (request.method === "POST" && url.pathname === "/device/approve") {
    const form = await readForm(request);
    for (const [, d] of DEVICES) if (d.userCode === form.user_code) d.approved = true;
    return json(200, { approved: true });
  }

  if (request.method === "POST" && url.pathname === "/token") {
    const form = await readForm(request);

    if (form.grant_type === "authorization_code") {
      const record = CODES.get(form.code ?? "");
      if (!record) return json(400, { error: "invalid_grant", detail: "code not recognized" });
      if (record.used) { CODES.delete(form.code); return json(400, { error: "invalid_grant", detail: "code presented a second time" }); }
      if (Date.now() > record.exp) return json(400, { error: "invalid_grant", detail: "code has expired" });
      if (form.client_id !== record.client_id) return json(400, { error: "invalid_grant", detail: "client does not match" });
      if (form.redirect_uri !== record.redirect_uri) return json(400, { error: "invalid_grant", detail: "redirect does not match" });
      if (!form.code_verifier || !isEqual(s256(form.code_verifier), record.challenge)) {
        return json(400, { error: "invalid_grant", detail: "verifier does not match" });
      }
      record.used = true;
      return json(200, { access_token: issueAccess(record.sub, record.client_id, record.scopes),
        token_type: "Bearer", expires_in: 600, scope: record.scopes.join(" ") });
    }

    if (form.grant_type === "client_credentials") {
      const [name, secret] = Buffer.from((request.headers.authorization ?? "").replace("Basic ", ""), "base64")
        .toString("utf8").split(":");
      const client = CLIENTS.get(name ?? "");
      if (!client || client.type !== "confidential" || !isEqual(secret ?? "", client.secret)) return json(401, { error: "invalid_client" });
      const requested = (form.scope ?? "").split(" ").filter(Boolean);
      if (!requested.every((k) => client.scopes.includes(k))) return json(400, { error: "invalid_scope" });
      return json(200, { access_token: issueAccess(name, name, requested), token_type: "Bearer",
        expires_in: 600, scope: requested.join(" ") });
    }

    if (form.grant_type === "urn:ietf:params:oauth:grant-type:device_code") {
      const record = DEVICES.get(form.device_code ?? "");
      if (!record) return json(400, { error: "invalid_grant" });
      if (!record.approved) return json(400, { error: "authorization_pending" });
      DEVICES.delete(form.device_code);
      return json(200, { access_token: issueAccess("U-1001", record.client_id, record.scopes),
        token_type: "Bearer", expires_in: 600, scope: record.scopes.join(" ") });
    }
    return json(400, { error: "unsupported_grant_type" });
  }
  json(404, { error: "not_found" });
}).listen(PORT, "127.0.0.1", () => console.log(`authorization server ${ISSUER}`));
```

The order of the checks at the authorization endpoint is not arbitrary. If the client
or the redirect address is not recognized, **no redirect happens**: the error stops in
the resource owner's browser. In a setup where the redirect address is not verified,
the error response — or worse, the authorization code — could be sent to an address
that is not registered. The redirect address is checked by exact match; prefix
matching or a wildcard is not accepted.

#### The Resource Server

The resource server verifies the token and looks for the **scope** required by the
endpoint.

```js
// resource-server.mjs — loan service: verifies the token and checks scope
// Usage: node resource-server.mjs [port]     (default 8552)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8552);
const KEY = Buffer.from("identity-provider-signing-key-32bytes!!!", "utf8");
const REQUIRED = new Map([["GET", "loan:read"], ["POST", "loan:write"]]);

const verify = (token) => {
  const [body, signature] = (token ?? "").split(".");
  if (!body || !signature) return null;
  const expected = createHmac("sha256", KEY).update(body).digest("base64url");
  const a = Buffer.from(expected, "utf8"), b = Buffer.from(signature, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
  if (claims.iss !== "http://127.0.0.1:8551" || claims.aud !== "loan-service") return null;
  return Math.floor(Date.now() / 1000) < claims.exp ? claims : null;
};

createServer((request, response) => {
  response.sendDate = false;
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8" });
    response.end(JSON.stringify(body));
  };
  const header = request.headers.authorization ?? "";
  const claims = header.startsWith("Bearer ") ? verify(header.slice(7)) : null;
  if (!claims) return json(401, { error: "invalid_token" });

  const required = request.url.split("?")[0] === "/loans" ? REQUIRED.get(request.method) : undefined;
  if (!required) return json(404, { error: "not_found" });
  if (!claims.scope.split(" ").includes(required)) {
    return json(403, { error: "insufficient_scope", required, given: claims.scope });
  }
  json(request.method === "GET" ? 200 : 201, { sub: claims.sub, client: claims.client_id, scope: claims.scope });
}).listen(PORT, "127.0.0.1", () => console.log(`resource server http://127.0.0.1:${PORT}`));
```

#### The Client

The client generates two values and keeps both of them itself: `state` and the PKCE
**verifier**. What is sent to the authorization server is not the verifier itself but
its digest — the **challenge**.

```js
// client.mjs — reading app: starts the flow, handles the callback, gets the token
// Usage: node client.mjs [port]     (default 8553)
import { createServer } from "node:http";
import { createHash, randomBytes } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8553);
const SELF = `http://127.0.0.1:${PORT}`, AS = "http://127.0.0.1:8551", RESOURCE = "http://127.0.0.1:8552";
const PENDING = new Map();       // state -> PKCE verifier; the record lives on the client
const s256 = (v) => createHash("sha256").update(v).digest("base64url");

createServer(async (request, response) => {
  response.sendDate = false;
  const url = new URL(request.url, SELF);
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8" });
    response.end(JSON.stringify(body));
  };

  if (url.pathname === "/start") {
    const state = randomBytes(12).toString("base64url"), verifier = randomBytes(32).toString("base64url");
    PENDING.set(state, verifier);
    const u = new URL(`${AS}/authorize`);
    for (const [name, value] of Object.entries({ response_type: "code", client_id: "reading-app",
      redirect_uri: `${SELF}/callback`, scope: "loan:read", state,
      code_challenge: s256(verifier), code_challenge_method: "S256" })) u.searchParams.set(name, value);
    return json(200, { authorizationUrl: u.toString() });
  }

  if (url.pathname === "/callback") {
    const state = url.searchParams.get("state");
    // 1. state must match the client's own record; if not, this flow does not belong here.
    if (!state || !PENDING.has(state)) return json(400, { error: "state did not match" });
    const verifier = PENDING.get(state);
    PENDING.delete(state);                                  // state is single-use
    if (url.searchParams.get("error")) return json(400, { error: url.searchParams.get("error") });

    // 2. The code is exchanged for a token together with the verifier.
    const tokenResponse = await fetch(`${AS}/token`, {
      method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code"),
        redirect_uri: `${SELF}/callback`, client_id: "reading-app", code_verifier: verifier }),
    });
    const token = await tokenResponse.json();
    if (!tokenResponse.ok) return json(400, { error: token });

    // 3. The resource server is called with the token.
    const resource = await fetch(`${RESOURCE}/loans`, { headers: { authorization: `Bearer ${token.access_token}` } });
    return json(200, { scope: token.scope, resourceStatus: resource.status, resourceBody: await resource.json() });
  }
  json(404, { error: "no such resource" });
}).listen(PORT, "127.0.0.1", () => console.log(`client ${SELF}`));
```

The two values' functions are separate, and both are mandatory.

`state` verifies that the returning request belongs to a flow the client **started
itself**. The client generates the `state` value, keeps it, and on the way back checks
whether it is on record; a callback that does not match is the result of a flow the
client did not start, and is rejected. A client that does not check `state` can bind
the code of an externally started flow to its own user's session. This is why the
setup must be rejected if `state` is not generated or not checked.

The PKCE verifier binds the authorization code to **the client that requested it**.
The authorization server stores the code together with the challenge; on the token
request, the verifier's digest is compared against this challenge. Because the
verifier never leaves the client's memory, a party that gets hold of the code from
somewhere else has no piece in hand to exchange it for a token. For public clients
that cannot store a secret, this is the code's only protection; this is why the
request must be rejected if `code_challenge` is missing or the method is not `S256`.

#### Running the Flow End to End

Three servers are brought up, and `curl` plays the browser's role: it gets the
authorization address from the client, goes to that address, and follows the returned
`Location` header.

```bash
node authorization-server.mjs > /dev/null 2>&1 & Y=$!
node resource-server.mjs > /dev/null 2>&1 & K=$!
node client.mjs > /dev/null 2>&1 & I=$!
sleep 1.2
ADDR=$(curl -s http://127.0.0.1:8553/start | sed -n 's/.*"authorizationUrl":"\([^"]*\)".*/\1/p')
echo "1) authorization address:"; echo "   $ADDR"
BACK=$(curl -s -o /dev/null -w '%{redirect_url}' "$ADDR")
echo "2) callback address:"; echo "   $BACK"
echo "3) client's result:"; curl -s "$BACK"; echo
kill $Y $K $I
```

```
1) authorization address:
   http://127.0.0.1:8551/authorize?response_type=code&client_id=reading-app&redirect_uri=http%3A%2F%2F127.0.0.1%3A8553%2Fcallback&scope=loan%3Aread&state=rGkCVLl6FMBezD1T&code_challenge=JtgCbusArRl8pHEGWyMBpV6P_TaFfjCEzQsAoKiAyM8&code_challenge_method=S256
2) callback address:
   http://127.0.0.1:8553/callback?code=7ICTKjl1Ro-fpP8DjsD0c9yzOUu-ADWy&state=rGkCVLl6FMBezD1T
3) client's result:
{"scope":"loan:read","resourceStatus":200,"resourceBody":{"sub":"U-1001","client":"reading-app","scope":"loan:read"}}
```

`state`, the challenge, and the code change on every run. The second line shows that
the only thing passing through the browser is the code; the token appears at no
address. In the resource server's response, `sub` states the member and `client`
states the application separately: the resource server can distinguish on whose
behalf a request comes from which application it comes through.

#### Checks at the Token Endpoint

The block below tries the code directly at the token endpoint; the verifier and the
challenge are generated in the shell.

```bash
node authorization-server.mjs > /dev/null 2>&1 & Y=$!
sleep 1
AS=http://127.0.0.1:8551
CALLBACK=http://127.0.0.1:8553/callback
VERIFIER=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
getCode() {
  curl -s -o /dev/null -w '%{redirect_url}' \
    "$AS/authorize?response_type=code&client_id=reading-app&redirect_uri=$CALLBACK&scope=loan:read&state=abc&code_challenge=$CHALLENGE&code_challenge_method=S256" \
    | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p'
}
exchange() { curl -s -d "$1" "$AS/token" | sed 's/"access_token":"[^"]*"/"access_token":"…"/'; }
CODE=$(getCode)
echo "valid exchange       : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app&code_verifier=$VERIFIER")"
echo "same code twice      : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app&code_verifier=$VERIFIER")"
CODE=$(getCode)
echo "verifier missing     : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=$CALLBACK&client_id=reading-app")"
CODE=$(getCode)
echo "another redirect     : $(exchange "grant_type=authorization_code&code=$CODE&redirect_uri=http://127.0.0.1:8553/other&client_id=reading-app&code_verifier=$VERIFIER")"
kill $Y
```

```
valid exchange       : {"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read"}
same code twice      : {"error":"invalid_grant","detail":"code presented a second time"}
verifier missing     : {"error":"invalid_grant","detail":"verifier does not match"}
another redirect     : {"error":"invalid_grant","detail":"redirect does not match"}
```

The code is single-use, and on its second presentation the record is dropped entirely
— the same reuse detection built for refresh tokens. A missing verifier and a wrong
verifier being presented get the same response; making a distinction would serve no
purpose other than leaking information to the outside.

#### Two More Flows

The **client credentials** flow is for the case where there is no resource owner: the
shelf terminal acts on its own behalf, proves its identity with its own secret, and
its scope comes from its own registration. The **device flow** is for devices that
cannot open a browser: the device gets a user code, the user approves that code on
another device, and the device polls in the meantime.

```bash
node authorization-server.mjs > /dev/null 2>&1 & Y=$!
node resource-server.mjs > /dev/null 2>&1 & K=$!
sleep 1
AS=http://127.0.0.1:8551
shorten() { sed 's/"access_token":"[^"]*"/"access_token":"…"/'; }
RESP=$(curl -s -u shelf-terminal:shelf-terminal-secret-1 -d 'grant_type=client_credentials&scope=loan:read loan:write' $AS/token)
echo "$RESP" | shorten
TOKEN=$(echo "$RESP" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "POST /loans: $(curl -s -X POST -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8552/loans)"
READONLY=$(curl -s -u shelf-terminal:shelf-terminal-secret-1 -d 'grant_type=client_credentials&scope=loan:read' $AS/token | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "POST /loans with read-only scope: $(curl -s -X POST -H "Authorization: Bearer $READONLY" http://127.0.0.1:8552/loans)"
DEVICE=$(curl -s -d 'client_id=shelf-terminal&scope=loan:read' $AS/device/code)
echo "$DEVICE"
DEVICE_CODE=$(echo "$DEVICE" | sed -n 's/.*"device_code":"\([^"]*\)".*/\1/p')
USER_CODE=$(echo "$DEVICE" | sed -n 's/.*"user_code":"\([^"]*\)".*/\1/p')
echo "poll before approval: $(curl -s -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$DEVICE_CODE" $AS/token)"
curl -s -o /dev/null -d "user_code=$USER_CODE" $AS/device/approve
echo "poll after approval : $(curl -s -d "grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=$DEVICE_CODE" $AS/token | shorten)"
kill $Y $K
```

```
{"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read loan:write"}
POST /loans: {"sub":"shelf-terminal","client":"shelf-terminal","scope":"loan:read loan:write"}
POST /loans with read-only scope: {"error":"insufficient_scope","required":"loan:write","given":"loan:read"}
{"device_code":"6kPpAgjAdv9XmiYLCEoGtVUW","user_code":"1DA15F","verification_uri":"http://127.0.0.1:8551/device","expires_in":600,"interval":1}
poll before approval: {"error":"authorization_pending"}
poll after approval : {"access_token":"…","token_type":"Bearer","expires_in":600,"scope":"loan:read"}
```

The device and user codes change on every run. The third line shows what scope does:
the same client, with a token it obtained with only the read scope, gets a 403 at the
write endpoint. Scope narrows not who the token belongs to, but **what it is allowed
to do**. In the device flow, polling gets `authorization_pending` until approval
arrives; the device must not poll more often than the `interval` value stated in the
response.

#### Summary

- Authorization delegation defines four roles: resource owner, client, authorization
  server, and resource server. The resource owner gives their credential only to the
  authorization server.
- In the authorization code flow, only the short-lived code passes through the
  browser; the token is obtained with a separate request that does not pass through
  the browser.
- The redirect address is checked by exact match, and nothing is sent to an
  unrecognized address; the setup must be rejected if `state` is not generated or
  checked, if `code_challenge` is missing, or if the method is not `S256`.
- The authorization code is single-use; on its second presentation the record is
  dropped, and a mismatched or missing verifier gets the same response.
- The client credentials flow is for machine calls with no resource owner; the device
  flow moves approval to another device for devices that cannot open a browser.
- Scope narrows what the token is allowed to do; the resource server looks for the
  scope required by the endpoint and returns a 403 when it is missing.

#### Next Step

None of these flows answered the question "who is this user?" The resource server
read the `sub` field in the token, but what that field means, which user it
corresponds to, and whether the token was really issued as the result of
authentication all fall outside the scope of the authorization delegation protocol. If
the client needs to recognize the user — to display their name, match their account,
open their session — a separate layer is required. The next lesson builds this layer:
the authorization server gives the client an **identity token** alongside the access
token, and the client verifies it with issuer, audience, duration, and signature
steps.

### OpenID Connect

Source: https://academia.sh/en/courses/authentication-and-authorization/openid-connect

Adding an identity layer on top of the authorization delegation protocol: the ID token issued with the openid scope, its separation from the access token by audience, asymmetric signature verification through the discovery document and key set, and the nonce, issuer, audience, and duration steps.

None of the previous lesson's flows answered the question "who is this user?" The
resource server read the `sub` field in the token, but which user that field
corresponds to, and whether the token was really issued as the result of
authentication, fall outside the scope of the authorization delegation protocol. The
access token says "this request can access these resources"; it does not say "this
person just authenticated their identity."

**OpenID Connect** fills this gap by adding an identity layer on top of the
authorization delegation protocol. When the client requests the `openid` scope, a
second token is added to the token response: the **ID token**. This token belongs not
to the resource server but to the **client**, and it states who the user is and when
they were authenticated.

#### Two Tokens, Two Recipients

The distinction shows up in the `aud` claim. The access token's audience is the
resource server; the ID token's audience is the client itself. Two rules follow from
this: the client does not send the ID token to the API, and the resource server does
not accept the ID token. When the two are confused, a token issued for one service
ends up being presented as proof to another.

The ID token is also issued **to be verified**, not to be carried around. The client
verifies it once, writes the information inside it into its own session, and has no
need to keep the token.

#### The Provider

The provider below extends the previous lesson's authorization code flow with the
identity layer: the `openid` scope and the `nonce` parameter are mandatory, the
signature is asymmetric, and public keys are published from a **key set** (JSON Web
Key Set) endpoint.

```js
// provider.mjs — identity layer: discovery document, key set, ID token, userinfo endpoint
// Usage: node provider.mjs [port]     (default 8561)
import { createServer } from "node:http";
import { generateKeyPairSync, createHash, randomBytes, sign, verify, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8561);
const ISSUER = `http://127.0.0.1:${PORT}`;
const CLIENT = { id: "reading-app", redirectUri: "http://127.0.0.1:8563/callback" };
const USER = { sub: "U-1001", name: "Clara Diaz", email: "clara.diaz@example.library", branch: "central" };

// Two keys: the current one and the retired one. Both stay in the key set.
const makeKey = (kid) => ({ kid, ...generateKeyPairSync("rsa", { modulusLength: 2048 }) });
const KEYS = [makeKey("2026-a"), makeKey("2025-b")];
const CURRENT = KEYS[0];

const CODES = new Map();
const b64 = (v) => Buffer.from(v).toString("base64url");
const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const isEqual = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

const issueJws = (key, claims) => {
  const data = `${b64(JSON.stringify({ alg: "RS256", typ: "JWT", kid: key.kid }))}.${b64(JSON.stringify(claims))}`;
  return `${data}.${sign("sha256", Buffer.from(data), key.privateKey).toString("base64url")}`;
};

const verifyJws = (token) => {
  const [h, b, s] = (token ?? "").split(".");
  if (!h || !b || !s) return null;
  const header = JSON.parse(Buffer.from(h, "base64url").toString("utf8"));
  const key = KEYS.find((k) => k.kid === header.kid);
  if (!key || header.alg !== "RS256") return null;
  if (!verify("sha256", Buffer.from(`${h}.${b}`), key.publicKey, Buffer.from(s, "base64url"))) return null;
  const claims = JSON.parse(Buffer.from(b, "base64url").toString("utf8"));
  return Math.floor(Date.now() / 1000) < claims.exp ? claims : null;
};

const readForm = (request) => new Promise((resolve) => {
  let v = ""; request.on("data", (p) => (v += p));
  request.on("end", () => resolve(Object.fromEntries(new URLSearchParams(v))));
});

createServer(async (request, response) => {
  response.sendDate = false;
  const url = new URL(request.url, ISSUER), s = url.searchParams;
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
    response.end(JSON.stringify(body));
  };

  if (url.pathname === "/.well-known/openid-configuration") {
    return json(200, {
      issuer: ISSUER, authorization_endpoint: `${ISSUER}/authorize`,
      token_endpoint: `${ISSUER}/token`, userinfo_endpoint: `${ISSUER}/userinfo`,
      jwks_uri: `${ISSUER}/keys`, id_token_signing_alg_values_supported: ["RS256"],
      scopes_supported: ["openid", "profile", "loan:read"], response_types_supported: ["code"],
    });
  }

  if (url.pathname === "/keys") {
    return json(200, {
      keys: KEYS.map((k) => ({
        ...k.publicKey.export({ format: "jwk" }), kid: k.kid, alg: "RS256", use: "sig",
      })),
    });
  }

  if (request.method === "GET" && url.pathname === "/authorize") {
    if (s.get("client_id") !== CLIENT.id || s.get("redirect_uri") !== CLIENT.redirectUri) {
      return json(400, { error: "invalid_request" });
    }
    const scopes = (s.get("scope") ?? "").split(" ");
    // The identity layer only kicks in when the "openid" scope is requested.
    if (!scopes.includes("openid")) return json(400, { error: "invalid_scope", detail: "no openid scope" });
    if (!s.get("state") || !s.get("nonce") || !s.get("code_challenge")) return json(400, { error: "invalid_request" });

    const code = randomBytes(24).toString("base64url");
    CODES.set(code, { challenge: s.get("code_challenge"), nonce: s.get("nonce"), scopes, used: false });
    const u = new URL(CLIENT.redirectUri);
    u.searchParams.set("code", code); u.searchParams.set("state", s.get("state"));
    response.writeHead(302, { location: u.toString() });
    return response.end();
  }

  if (request.method === "POST" && url.pathname === "/token") {
    const form = await readForm(request);
    const record = CODES.get(form.code ?? "");
    if (!record || record.used) return json(400, { error: "invalid_grant" });
    if (!form.code_verifier || !isEqual(s256(form.code_verifier), record.challenge)) return json(400, { error: "invalid_grant" });
    record.used = true;

    const now = Math.floor(Date.now() / 1000);
    // The access token is issued for the resource server; the ID token for the client.
    const access = issueJws(CURRENT, { iss: ISSUER, sub: USER.sub, aud: "loan-service",
      scope: record.scopes.join(" "), exp: now + 600, iat: now });
    const idToken = issueJws(CURRENT, { iss: ISSUER, sub: USER.sub, aud: CLIENT.id,
      nonce: record.nonce, auth_time: now, exp: now + 600, iat: now,
      name: USER.name, email: USER.email });
    return json(200, { access_token: access, id_token: idToken, token_type: "Bearer", expires_in: 600 });
  }

  if (url.pathname === "/userinfo") {
    // The userinfo endpoint requires an access token; the ID token is not valid here.
    const header = request.headers.authorization ?? "";
    if (!header.startsWith("Bearer ")) return json(401, { error: "invalid_token" });
    const claims = verifyJws(header.slice(7));
    if (!claims) return json(401, { error: "invalid_token" });
    if (claims.aud !== "loan-service") return json(401, { error: "invalid_token", detail: "audience is not this endpoint" });
    return json(200, USER);
  }
  json(404, { error: "not_found" });
}).listen(PORT, "127.0.0.1", () => console.log(`provider ${ISSUER}`));
```

The key set publishes two keys: the current one and the retired one. Key rotation can
therefore happen without interruption — a new key is added to the set, signing moves
to it, the old key stays in the set until the tokens signed with it run out their
lifetime, and only then is it removed. The header's `kid` field tells the verifier
which key to select.

```bash
node provider.mjs 8561 > /dev/null 2>&1 & S=$!
sleep 1
curl -s http://127.0.0.1:8561/keys | sed 's/"n":"[^"]\{20\}[^"]*"/"n":"…"/g'
echo
kill $S
```

```
{"keys":[{"kty":"RSA","n":"…","e":"AQAB","kid":"2026-a","alg":"RS256","use":"sig"},{"kty":"RSA","n":"…","e":"AQAB","kid":"2025-b","alg":"RS256","use":"sig"}]}
```

The set holds only public keys (`n` is truncated). The verifying party never sees the
private key; the power to issue tokens stays with the provider. This is how the
scaling problem that shared-secret signing created in the JSON Web Tokens lesson gets
solved.

#### The Client and the Verification Steps

The client hardcodes no address; it reads them from the discovery document. After
receiving the token, verification consists of questions answered in sequence.

```js
// client.mjs — reads the discovery document, runs the flow, verifies the ID token step by step
// Usage: while node provider.mjs 8561 &  is running,  node client.mjs
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";

const PROVIDER = "http://127.0.0.1:8561";
const CLIENT_ID = "reading-app";
const REDIRECT_URI = "http://127.0.0.1:8563/callback";
const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const decode = (p) => JSON.parse(Buffer.from(p, "base64url").toString("utf8"));

// 1) Discovery document: endpoint addresses and supported algorithms are read from here.
const discovery = await (await fetch(`${PROVIDER}/.well-known/openid-configuration`)).json();
const keySet = await (await fetch(discovery.jwks_uri)).json();
console.log("issuer            :", discovery.issuer);
console.log("signing algorithms:", discovery.id_token_signing_alg_values_supported.join(", "));
console.log("key set           :", keySet.keys.map((k) => `${k.kid} (${k.kty})`).join(", "));

// 2) Flow: the client generates and keeps state, nonce, and the PKCE verifier itself.
const state = randomBytes(9).toString("base64url");
const nonce = randomBytes(9).toString("base64url");
const verifier = randomBytes(32).toString("base64url");
const u = new URL(discovery.authorization_endpoint);
for (const [name, value] of Object.entries({ response_type: "code", client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI, scope: "openid profile loan:read", state, nonce,
  code_challenge: s256(verifier), code_challenge_method: "S256" })) u.searchParams.set(name, value);

const authResponse = await fetch(u, { redirect: "manual" });
const callback = new URL(authResponse.headers.get("location"));
if (callback.searchParams.get("state") !== state) throw new Error("state did not match");

const tokens = await (await fetch(discovery.token_endpoint, {
  method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({ grant_type: "authorization_code", code: callback.searchParams.get("code"),
    redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier }),
})).json();

// 3) Verifying the ID token. Each step corresponds to a separate question.
const verifyIdToken = (token, { expectedNonce } = {}) => {
  const [h, b, s] = (token ?? "").split(".");
  if (!h || !b || !s) return "format";
  const header = decode(h);
  if (!discovery.id_token_signing_alg_values_supported.includes(header.alg)) return "algorithm";
  const jwk = keySet.keys.find((k) => k.kid === header.kid && k.alg === header.alg);
  if (!jwk) return "key not recognized";
  const publicKey = createPublicKey({ key: jwk, format: "jwk" });
  if (!verify("sha256", Buffer.from(`${h}.${b}`), publicKey, Buffer.from(s, "base64url"))) return "signature";

  const claims = decode(b), now = Math.floor(Date.now() / 1000);
  if (claims.iss !== discovery.issuer) return "issuer";
  const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
  if (!aud.includes(CLIENT_ID)) return `audience (${claims.aud})`;
  if (now >= claims.exp) return "expired";
  if (expectedNonce !== undefined && claims.nonce !== expectedNonce) return "nonce";
  return { sub: claims.sub, name: claims.name, authenticationTime: claims.auth_time };
};

const print = (name, result) => console.log(name.padEnd(30), typeof result === "string" ? `reject (${result})` : `accept → ${JSON.stringify(result)}`);
print("ID token", verifyIdToken(tokens.id_token, { expectedNonce: nonce }));
print("different expected nonce", verifyIdToken(tokens.id_token, { expectedNonce: "another-value" }));
print("access token when presented", verifyIdToken(tokens.access_token, { expectedNonce: nonce }));

const [h, b, s] = tokens.id_token.split(".");
const bodyAltered = Buffer.from(JSON.stringify({ ...decode(b), sub: "Y-0002" })).toString("base64url");
print("body altered", verifyIdToken(`${h}.${bodyAltered}.${s}`, { expectedNonce: nonce }));
const otherKid = Buffer.from(JSON.stringify({ ...decode(h), kid: "1999-z" })).toString("base64url");
print("unrecognized key id", verifyIdToken(`${otherKid}.${b}.${s}`, { expectedNonce: nonce }));

// 4) The userinfo endpoint requires an access token.
const askUserinfo = async (token) => {
  const response = await fetch(discovery.userinfo_endpoint, { headers: { authorization: `Bearer ${token}` } });
  return `${response.status} ${JSON.stringify(await response.json())}`;
};
console.log("userinfo endpoint / access token:", await askUserinfo(tokens.access_token));
console.log("userinfo endpoint / ID token    :", await askUserinfo(tokens.id_token));
```

```bash
node provider.mjs 8561 > /dev/null 2>&1 & S=$!
sleep 1.2
node client.mjs
kill $S
```

```
issuer            : http://127.0.0.1:8561
signing algorithms: RS256
key set           : 2026-a (RSA), 2025-b (RSA)
ID token                       accept → {"sub":"U-1001","name":"Clara Diaz","authenticationTime":1787079690}
different expected nonce       reject (nonce)
access token when presented    reject (audience (loan-service))
body altered                   reject (signature)
unrecognized key id            reject (key not recognized)
userinfo endpoint / access token: 200 {"sub":"U-1001","name":"Clara Diaz","email":"clara.diaz@example.library","branch":"central"}
userinfo endpoint / ID token    : 401 {"error":"invalid_token","detail":"audience is not this endpoint"}
```

The timestamp changes on every run. The five lines confirm five separate steps.

**Algorithm** is bounded by the list the discovery document states, and is not taken
from the token's header. The rule established in the JSON Web Tokens lesson applies
here too: the algorithms to accept come from the verifier's configuration.

**Key selection** happens between `kid` and the keys in the set. A `kid` with no
counterpart in the set stops verification before it starts. It is normal for the
verifier to refetch the set for a `kid` it does not recognize; but this refetching
must be rate-limited, otherwise every unrecognized value triggers an outbound call.

**Audience** is the step that separates the ID token from the access token. In the
third line, the access token is presented as though it were an ID token and is
rejected because its `aud` field is `loan-service`. The last line shows the same
distinction in reverse: the ID token gets a 401 when presented to the userinfo
endpoint. Even though both tokens come from the same issuer and are signed with the
same key, neither substitutes for the other; the only field that makes the distinction
is the `aud` claim.

**Nonce** binds the ID token to **this** session of the client. The client generates
the value, sends it along with the flow, and looks for the same one in the returned
token. A token that does not match is not recognized if it belongs to another flow.
`state` verifies that the request belongs to the client; `nonce` verifies that the
token belongs to this request; the two are separate questions, and both are required.

The **userinfo endpoint** returns additional information not present in the ID token,
using the access token. Branch information comes back at this endpoint but is not in
the ID token; leaving the information to a separate call instead of growing the token
keeps the header carried on every request small.

#### The ID Token's Limit

The ID token is a statement about the past: at the moment `auth_time`, this user's
identity was authenticated. It is not an ongoing authorization. After verifying the
token, the client opens its own session and manages that session by its own rules —
every decision from the Session-Based Authentication lesson applies here too,
including renewing the session identifier at login.

The rule that follows is this: the ID token is not attached to every request, is not
sent to the resource server, and is not kept for long. It is verified once, its
counterpart is written to the local session, and its job is done.

#### Summary

- The authorization delegation protocol delegates access authorization; the identity
  layer adds the information of who the user is on top of that, and kicks in with the
  `openid` scope.
- The ID token's audience is the client, the access token's audience is the resource
  server; even signed with the same key, neither substitutes for the other.
- The discovery document states the endpoint addresses and the accepted signing
  algorithms; the key set publishes public keys with `kid` and makes key rotation
  uninterrupted.
- Verification passes in order through the algorithm, key selection, signature,
  issuer, audience, duration, and nonce steps; each step answers a separate question.
- `state` verifies that the request belongs to the client, `nonce` verifies that the
  token belongs to that request; neither can substitute for the other.
- The ID token is a statement of a past authentication; it is verified once, its
  counterpart is written to the local session, and it is not attached to every
  request.

#### Next Step

In this setup, the identity provider and the loan service were part of the same
organization: the provider knew the member registry, and the `sub` field corresponded
directly to the member code. This assumption breaks down once district libraries
connect to a shared loan system — each organization has its own user directory, its
own authentication rules, and its own user identities. The next lesson builds this
arrangement: carrying an identity one organization authenticated to another
organization with a signed **assertion**, checking that assertion by its validity
window and audience, preventing the same assertion from being used a second time, and
translating externally supplied attributes into a local role.

### Enterprise Federation

Source: https://academia.sh/en/courses/authentication-and-authorization/enterprise-federation

Checking an identity carried by an assertion through signature, issuer, audience, and duration window; preventing reuse; mapping externally supplied attributes to a local role with an explicit table; and the limit of logout.

The previous lesson added the identity layer on top of the authorization delegation
protocol: the application learned who the user was from a signed document. The party
that issued the document and the party that consumed it were parts of the same
organization.

In enterprise federation, this assumption is dropped. The user authenticates their
identity **at their own organization**; the library service accepts the user by
trusting the document that organization issued. The library never sees the password
and never keeps a user list. The name of this arrangement is **single sign-on**, and
its carrier is the **assertion**.

#### The Direction and Limit of Trust

Federation has two parties: the **identity provider** that authenticates the
identity, and the **service provider** that manages access to the resource. Trust is
one-directional and limited: the service provider trusts the identity provider's
claim of "this person is who they are"; it makes the decision of "this person can do
this" itself.

This distinction is the cross-organization form of the distinction in the course's
first lesson. Identity comes from outside, authorization stays inside. If an
externally supplied attribute is translated directly into authorization, the
neighboring organization's group management ends up determining our authorization
system.

The account below runs the issuance, verification, and mapping of an assertion to a
local role end to end.

```bash
cat > federation.mjs <<'EOF'
// federation.mjs — issuing, verifying, and mapping an assertion to a local role
import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";

const SECRET = Buffer.from("org-signing-key-example");
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
const sign = (body) => createHmac("sha256", SECRET).update(body).digest("base64url");

// The identity-providing organization issues an assertion.
function issueAssertion({ subject, emailVerified, groups, audience, seconds, time }) {
  const body = b64({
    iss: "https://auth.north-university.test",
    sub: subject, aud: audience,
    iat: time, exp: time + seconds,
    jti: randomBytes(6).toString("hex"),
    email_verified: emailVerified, groups,
  });
  return body + "." + sign(body);
}

// The service-providing organization verifies the assertion. Each step is a separate decision.
const USED = new Set();
function verify(assertion, { audience, now, issuer }) {
  const [body, signature] = assertion.split(".");
  const expected = sign(body);
  if (signature.length !== expected.length ||
      !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return "reject: signature";
  const claims = JSON.parse(Buffer.from(body, "base64url"));
  if (claims.iss !== issuer) return "reject: issuer";
  if (claims.aud !== audience) return "reject: audience";
  if (now >= claims.exp) return "reject: expired";
  if (now < claims.iat) return "reject: future-dated";
  if (USED.has(claims.jti)) return "reject: reuse";
  USED.add(claims.jti);
  return "accept";
}

const TIME = 1_770_000_000;                       // fixed time: so the output is reproducible
const AUDIENCE = "https://library.north-university.test";
const ISSUER = "https://auth.north-university.test";
const base = { subject: "std-4417", emailVerified: true, groups: ["student", "science-faculty"],
                audience: AUDIENCE, seconds: 300, time: TIME };

const trials = [
  ["valid assertion", issueAssertion(base), { audience: AUDIENCE, now: TIME + 10, issuer: ISSUER }],
  ["written to another org", issueAssertion({ ...base, audience: "https://other.test" }), { audience: AUDIENCE, now: TIME + 10, issuer: ISSUER }],
  ["expired", issueAssertion({ ...base, seconds: 5 }), { audience: AUDIENCE, now: TIME + 60, issuer: ISSUER }],
  ["signature broken", issueAssertion(base).slice(0, -1) + "x", { audience: AUDIENCE, now: TIME + 10, issuer: ISSUER }],
  ["from another issuer", issueAssertion(base), { audience: AUDIENCE, now: TIME + 10, issuer: "https://fake.test" }],
];
console.log("trial                      result");
for (const [name, assertion, condition] of trials) console.log(`  ${name.padEnd(24)} ${verify(assertion, condition)}`);

// If the same assertion is presented twice
const again = issueAssertion(base);
console.log(`  ${"first presentation".padEnd(24)} ${verify(again, { audience: AUDIENCE, now: TIME + 10, issuer: ISSUER })}`);
console.log(`  ${"same assertion again".padEnd(24)} ${verify(again, { audience: AUDIENCE, now: TIME + 20, issuer: ISSUER })}`);

// Externally supplied attributes are mapped to a local role; the mapping table must be explicit.
const MAPPING = [
  { condition: (c) => c.groups.includes("library-staff"), role: "clerk" },
  { condition: (c) => c.groups.includes("student") && c.email_verified, role: "member" },
  { condition: (c) => c.groups.includes("faculty"), role: "member" },
];
function localRole(assertion) {
  const c = JSON.parse(Buffer.from(assertion.split(".")[0], "base64url"));
  const matched = MAPPING.find((m) => m.condition(c));
  return { subject: c.sub, groups: c.groups.join(","), role: matched ? matched.role : "no role" };
}
console.log("\nexternal subject           groups                      local role");
for (const groups of [["student", "science-faculty"], ["library-staff"], ["guest"]]) {
  const result = localRole(issueAssertion({ ...base, groups }));
  console.log(`  ${result.subject.padEnd(24)} ${result.groups.padEnd(27)} ${result.role}`);
}
const unverified = localRole(issueAssertion({ ...base, emailVerified: false, groups: ["student"] }));
console.log(`  ${unverified.subject.padEnd(24)} ${"student (email not verified)".padEnd(27)} ${unverified.role}`);
EOF
node federation.mjs
```

```text
trial                      result
  valid assertion          accept
  written to another org   reject: audience
  expired                  reject: expired
  signature broken         reject: signature
  from another issuer      reject: issuer
  first presentation       accept
  same assertion again     reject: reuse

external subject           groups                      local role
  std-4417                 student,science-faculty     member
  std-4417                 library-staff               clerk
  std-4417                 guest                       no role
  std-4417                 student (email not verified) no role
```

#### Verification Is Not One Step

The output's first section shows five separate rejections, and each comes from a
separate check. The signature says the document was not altered in transit — but it
is not sufficient on its own. The issuer check verifies that the document came **from
the expected organization**; if this check is skipped, any organization capable of
issuing a validly signed document can identify our users.

The audience check is the one most often skipped. The identity provider issues
documents for more than one service for the same user; if a document issued for one
service is accepted at another, a weakly protected service becomes a gateway into a
strongly protected one. The document's audience states who is meant to accept it.

The duration window is two-sided: the past side checks the document's lifetime, the
future side checks clock skew. Keeping the window narrow shortens the usable time if
the document is intercepted.

#### The Same Document Cannot Be Used Twice

The second section's two lines show the reuse defense. The document carries a unique
identifier, and this identifier is recorded when the document is consumed; the same
document is rejected when presented a second time.

The record's lifetime is as long as the document's lifetime: storing the identifier
of an expired document is unnecessary, because the duration check already rejects it.
This keeps the record from growing without bound — the reuse list is a record that
looks back only as far as the validity window.

#### The Attribute-to-Role Mapping Must Be Explicit

The third section runs the mapping table. Externally supplied group names are not
used directly as a role; they are translated according to a local table. A group with
no match gives a "no role" result, and the user cannot access the resource.

The last line shows an additional condition: being in the student group is not
enough — the email must also be verified. Not every attribute the identity provider
sends carries the same reliability; which attribute to trust under which condition is
the service provider's decision.

The practical consequence of the table being explicit is this: when the neighboring
organization opens a new group, nothing changes in our system. Authorization only
expands when a row is added to the mapping table, and that row passes through review.

#### The Limit of Logout

In federation, logout is not one-sided. When the user logs out of the library
service, their session at the identity provider persists; the redirect on the next
request silently issues a new document, and the user appears never to have logged
out.

This has two counterparts. A local logout ends only the session at the service
provider and does not satisfy a "sign in with another account" request. A central
logout also ends the session at the identity provider and affects every service tied
to that provider. Shared machines require the second one; the user must be told
plainly which one was done.

#### Summary

- In federation, identity comes from outside and authorization stays inside: the
  assertion states who the person is, and the service provider determines what they
  can do.
- Verification is not one step; signature, issuer, audience, and duration window are
  each checked separately — in the model, each of these checks produced a separate
  rejection.
- The document is consumed once with its unique identifier; the reuse list looks back
  only as far as the document's validity window.
- Externally supplied attributes are translated into a local role with an explicit
  table; an unmatched group gets no authorization, and the neighboring organization
  opening a group does not change our authorizations.
- Local logout and central logout are different things; which one was done must be
  communicated to the user.

#### Next Step

In every flow up to this point, there was a human on the other end: someone entering
a password, reading the consent screen, providing the second factor. But some of the
callers to the library service are not human — a batch job running overnight, the
neighboring organization's catalog syncer, a dashboard. None of these has a password
or a consent screen. The next lesson addresses the credential used for these callers:
how it is issued, where it is stored, and how it is rotated.

### API Keys

Source: https://academia.sh/en/courses/authentication-and-authorization/api-keys

The entropy of the long-lived credential issued for machine callers, the operational value of the prefix and trailing characters, storing it as a hash, rotating it with a grace window, and limiting it with scope.

In every flow up to this point, there was a human on the other end: someone entering
a password, reading the consent screen, providing the second factor. But some of the
callers to the library service are not human. A batch job running overnight, the
neighboring organization's catalog syncer, a dashboard — none of these has a password,
and none can read a consent screen.

The credential used for these callers is called an **API key**. Its structure is
plain: a long, random string. Its plainness is both its strength and its weakness — it
is easy to carry, and easy to leak.

#### What the Key Proves

The API key proves **who you are**, not what you can do. This distinction is the
machine-valid form of the distinction in the course's first lesson: the key is
verified, and authorization is limited separately.

What sets the key apart from a token is its lifetime. A token lives briefly and is
renewed; a key stays the same for months and sits in configuration files, continuous
integration environments, container images. This is why two questions stand out in a
key's design: how much damage does it do when it leaks, and how quickly can it be
replaced?

The account below runs the key's entire life cycle, from issuance to rotation.

```bash
cat > key.mjs <<'EOF'
// key.mjs — API key issuance, storage, verification, and rotation
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

// 1. Issuance: prefix + random body. The prefix keeps the key's owner service readable.
const PREFIX = "polar_";
function generateKey(bytes) {
  const body = randomBytes(bytes).toString("base64url");
  return PREFIX + body;
}
const entropy = (bytes) => bytes * 8;
console.log("body bytes  entropy (bits)  example length  brute-force attempts needed (2^n)");
for (const b of [8, 16, 24, 32]) {
  console.log(`${String(b).padStart(10)} ${String(entropy(b)).padStart(14)} ${String(generateKey(b).length).padStart(14)}  2^${entropy(b) - 1}`);
}

// 2. Storage: not the key itself but its hash is kept; the prefix and last four characters are searchable.
const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE api_key (
  id TEXT PRIMARY KEY, owner TEXT NOT NULL, hash TEXT NOT NULL,
  tail TEXT NOT NULL, status TEXT NOT NULL, valid_until INTEGER)`);
const hash = (a) => createHash("sha256").update(a).digest("hex");
function save(id, owner, key, validUntil = null) {
  db.prepare("INSERT INTO api_key VALUES (?,?,?,?,?,?)")
    .run(id, owner, hash(key), key.slice(-4), "active", validUntil);
}
const old = generateKey(24);
save("AK-1", "catalog-syncer", old);
// The hash and tail differ on every run; only their lengths are printed here.
console.log("\nstored record (the key itself is absent):");
for (const s of db.prepare("SELECT id, owner, length(hash) AS hash_length, length(tail) AS tail_length, status FROM api_key").all())
  console.log("  " + JSON.stringify(s));

// 3. Verification: constant-time comparison; the reason for a mismatch is not stated openly.
const TIME = 1_770_000_000;
function verify(presented, time) {
  const computedHash = hash(presented);
  const row = db.prepare("SELECT * FROM api_key WHERE tail = ?").all(presented.slice(-4))
    .find((s) => {
      const a = Buffer.from(s.hash, "hex"), b = Buffer.from(computedHash, "hex");
      return a.length === b.length && timingSafeEqual(a, b);
    });
  if (!row) return "reject: invalid key";
  if (row.status !== "active") return `reject: key ${row.status}`;
  if (row.valid_until !== null && time >= row.valid_until) return "reject: expired";
  return `accept (${row.owner})`;
}
console.log("\nverification attempts");
console.log("  valid key              -> " + verify(old, TIME));
console.log("  random different key   -> " + verify(generateKey(24), TIME));

// 4. Rotation: a new key is issued, the old one stays valid through a grace window.
const fresh = generateKey(24);
save("AK-2", "catalog-syncer", fresh);
db.prepare("UPDATE api_key SET valid_until = ? WHERE id = 'AK-1'").run(TIME + 3600);
const stages = [
  ["before rotation", TIME - 10],
  ["in the grace window", TIME + 60],
  ["after the window closes", TIME + 7200],
];
console.log("\nstage                      old key                 new key");
for (const [name, time] of stages)
  console.log(`  ${name.padEnd(26)} ${verify(old, time).padEnd(23)} ${verify(fresh, time)}`);

// 5. After a leak: revocation takes effect immediately, regardless of duration.
db.prepare("UPDATE api_key SET status = 'revoked' WHERE id = 'AK-2'").run();
console.log("\nafter revocation, new key -> " + verify(fresh, TIME + 60));

// 6. Scope and source limitation: the key alone is not authorization.
const LIMITS = { "AK-1": { scopes: ["catalog:read"], networkBlocks: ["10.0.0.0/8"] } };
console.log("\nper-key limit: " + JSON.stringify(LIMITS["AK-1"]));
console.log("  the key is verified; authorization is further limited by scope and source network.");
EOF
node key.mjs
```

```text
body bytes  entropy (bits)  example length  brute-force attempts needed (2^n)
         8             64             17  2^63
        16            128             28  2^127
        24            192             38  2^191
        32            256             49  2^255

stored record (the key itself is absent):
  {"id":"AK-1","owner":"catalog-syncer","hash_length":64,"tail_length":4,"status":"active"}

verification attempts
  valid key              -> accept (catalog-syncer)
  random different key   -> reject: invalid key

stage                      old key                 new key
  before rotation            accept (catalog-syncer) accept (catalog-syncer)
  in the grace window        accept (catalog-syncer) accept (catalog-syncer)
  after the window closes    reject: expired         accept (catalog-syncer)

after revocation, new key -> reject: key revoked

per-key limit: {"scopes":["catalog:read"],"networkBlocks":["10.0.0.0/8"]}
  the key is verified; authorization is further limited by scope and source network.
```

#### Issuance: Entropy and Prefix

The output's first table shows the body length's conversion into entropy. A
sixteen-byte body carries a hundred and twenty-eight bits of entropy; finding it by
brute force is practically impossible. Longer keys add no extra security — they only
bloat configuration files.

The prefix, on the other hand, is not for security but for **operations**. When a key
leaks into a log or into source code, the prefix states which service it belongs to
at a glance; leak scanners work off this prefix too. For the same reason, the key's
last few characters are kept in the record: this is the only way the user can answer
the question "which key should I revoke?"

#### Storage: The Key Itself Is Not Kept

The second section shows the stored record, and the record has no key itself in it.
What is kept is its hash, its last four characters for lookup, and its status.

The consequence of this is that the key can be shown **only once**: it is given to the
user at the moment it is issued, and cannot be read again after that. A lost key is
not recovered — a new one is issued. This restriction looks like a loss of
convenience, but it prevents keys from being directly usable in a database leak.

There is a difference from storing a password: an API key has high entropy and is
random, and is not exposed to a dictionary attack. This is why a slow hash is not
needed; a fast hash is sufficient, and preferred precisely because it is verified on
every request. Verification still needs to be done with a **constant-time**
comparison.

#### Rotation: The Grace Window

The fourth section shows rotation stage by stage. A new key is issued and the old key
is given an expiration date; during the grace window, **both** are valid. This gives
the calling side time to update its configuration.

Without a window, rotation is an outage: the moment the key changes, every job
running with the old configuration drops. The window's length is chosen according to
the calling side's deployment frequency, and at the end of the window it is verified
**by measurement** that the old key is really no longer in use — the last-used time is
kept in the record.

The fifth section shows the exception: in the case of a leak, there is no window.
Revocation takes effect immediately and runs before the duration check; an outage is
preferable to a leaked key staying open.

#### The Key Alone Is Not Authorization

The last section lists the per-key limits. A verified key states who the caller is;
what they can do is limited by scope, and where they can call from is limited by a
source network restriction. The read-only key of a syncer must not be able to reach
write endpoints — this is the rule from the Scopes and Permissions lesson applied to
machine callers.

Where the key is stored is also part of the design. A key written into source code, a
container image, or version control is open to everyone who can access that
repository. The right place is a secret store read at runtime; the boundary from the
Separating Secrets lesson applies here too.

#### Summary

- An API key is a long-lived credential used for machine callers with no consent
  screen; it proves who you are, and scope determines authorization.
- The prefix and trailing characters are for operations: leak scanning and the "which
  key should I revoke?" question work off them.
- The record keeps the key's hash, not the key itself; the key is shown only at the
  moment it is issued. Because it has high entropy, a fast hash is sufficient, and
  the comparison is constant-time.
- In rotation, the old and new keys are valid together during a grace window; in a
  leak there is no window, revocation is effective immediately.
- The key lives in a secret store; it is not written into source code, a container
  image, or version control.

#### Next Step

The key is the machine caller's only proof: whoever holds it is the caller. For human
users, relying on a single proof is not enough — once the password is compromised, the
whole account opens up. The course's last lesson on this topic addresses a second
layer of proof: what kinds of factors exist, how a time-based code is verified, how
clock skew is accommodated, and why recovery codes are single-use.

### Multi-Factor Authentication

Source: https://academia.sh/en/courses/authentication-and-authorization/multi-factor-authentication

The independence of factor classes, deriving the time-based code and the cost of the clock skew window, preventing the same code from being used a second time, and storing recovery codes as single-use.

The previous lesson addressed the machine caller's single proof: whoever holds the
key is the caller. For human users, relying on a single proof is not enough. Once a
password is compromised — it may have been reused, typed into a phishing page, or
come from another leak — the whole account opens up.

**Multi-factor authentication** ties login to more than one independent proof. The
source of the gain is not the count but **independence**: if two proofs can be
compromised through the same path, the second proof adds nothing.

#### Factor Classes

Proofs fall into three classes: something known (a password, a recovery code),
something possessed (an app on the phone, a hardware key), and something the person
themselves is (biometric). Multi-factor authentication requires at least two proofs
**from different classes**.

The account below issues the time-based code, verifies it, and compares factor
classes.

```bash
cat > mfa.mjs <<'EOF'
// mfa.mjs — time-based one-time code, clock skew, and recovery codes
import { createHmac, createHash, timingSafeEqual } from "node:crypto";

// 1. Time-based code (TOTP): derived from the shared secret and the time slice.
const STEP = 30;                       // seconds
const DIGITS = 6;
function totp(secret, time, step = STEP) {
  const counter = Math.floor(time / step);
  const buffer = Buffer.alloc(8);
  buffer.writeBigUInt64BE(BigInt(counter));
  const digest = createHmac("sha1", secret).update(buffer).digest();
  const offset = digest[digest.length - 1] & 0x0f;
  const value = digest.readUInt32BE(offset) & 0x7fffffff;
  return String(value % 10 ** DIGITS).padStart(DIGITS, "0");
}

const SECRET = Buffer.from("shared-example-secret");
const TIME = 1_770_000_000;
console.log("time            slice       code");
for (const diff of [0, 29, 30, 60]) {
  const t = TIME + diff;
  console.log(`  +${String(diff).padEnd(3)} seconds  ${String(Math.floor(t / STEP)).padEnd(11)} ${totp(SECRET, t)}`);
}

// 2. Clock skew window: how many slices back/forward should the server look?
function verify(secret, submitted, time, window) {
  for (let k = -window; k <= window; k++) {
    const expected = totp(secret, time + k * STEP);
    const a = Buffer.from(submitted), b = Buffer.from(expected);
    if (a.length === b.length && timingSafeEqual(a, b)) return { accepted: true, sliceOffset: k };
  }
  return { accepted: false };
}
const userTime = TIME - 45;                 // the user's clock is 45 seconds behind
const code = totp(SECRET, userTime);
console.log("\nwindow   result    slice offset   attempts");
for (const p of [0, 1, 2]) {
  const result = verify(SECRET, code, TIME, p);
  console.log(`${String(p).padStart(6)}  ${(result.accepted ? "accept" : "reject").padEnd(9)} ${String(result.accepted ? result.sliceOffset : "-").padEnd(13)} ${2 * p + 1}`);
}
console.log("as the window grows, the number of accepted codes increases: every extra slice widens the guessing space.");

// 3. The same code being used a second time is prevented.
const USED = new Set();
function singleUse(subject, submitted, time) {
  const result = verify(SECRET, submitted, time, 1);
  if (!result.accepted) return "reject: invalid code";
  const key = `${subject}:${Math.floor((time + result.sliceOffset * STEP) / STEP)}`;
  if (USED.has(key)) return "reject: code already used";
  USED.add(key);
  return "accept";
}
const currentCode = totp(SECRET, TIME);
console.log("\nsingle-use attempts");
console.log("  first submission  -> " + singleUse("U-1001", currentCode, TIME));
console.log("  second submission -> " + singleUse("U-1001", currentCode, TIME + 5));

// 4. Factor types and recovery: no two factors should fall into the same class.
const FACTORS = [
  { name: "password",                class: "knowledge",  offline: true,  transferable: true },
  { name: "time-based code",         class: "possession", offline: true,  transferable: false },
  { name: "SMS code",                class: "possession", offline: false, transferable: false },
  { name: "hardware security key",   class: "possession", offline: true,  transferable: false },
  { name: "recovery code",           class: "knowledge",  offline: true,  transferable: true },
];
console.log("\nfactor                      class      offline     transferable");
for (const f of FACTORS)
  console.log(`  ${f.name.padEnd(26)} ${f.class.padEnd(10)} ${(f.offline ? "yes" : "no").padEnd(11)} ${f.transferable ? "yes" : "no"}`);
const sameClass = FACTORS.filter((f) => f.class === "knowledge").length;
console.log(`factors in the knowledge class: ${sameClass} — if the second factor is chosen from the first one's class, there is no gain.`);

// 5. Recovery codes: stored single-use and as a hash.
const recovery = ["3f8a-91cd", "77b2-4e0a", "c15d-8823"];
const store = new Map(recovery.map((c) => [createHash("sha256").update(c).digest("hex"), "unused"]));
function tryRecovery(code) {
  const h = createHash("sha256").update(code).digest("hex");
  if (!store.has(h)) return "reject: invalid code";
  if (store.get(h) === "used") return "reject: code was already used";
  store.set(h, "used");
  return "accept";
}
console.log("\nrecovery code attempts");
for (const [name, code] of [["valid code", recovery[0]], ["same code again", recovery[0]], ["not in the list", "0000-0000"]])
  console.log(`  ${name.padEnd(18)} -> ${tryRecovery(code)}`);
console.log(`remaining usable codes: ${[...store.values()].filter((d) => d === "unused").length} / ${recovery.length}`);
EOF
node mfa.mjs
```

```text
time            slice       code
  +0   seconds  59000000    634551
  +29  seconds  59000000    634551
  +30  seconds  59000001    787106
  +60  seconds  59000002    324776

window   result    slice offset   attempts
     0  reject    -             1
     1  reject    -             3
     2  accept    -2            5
as the window grows, the number of accepted codes increases: every extra slice widens the guessing space.

single-use attempts
  first submission  -> accept
  second submission -> reject: code already used

factor                      class      offline     transferable
  password                   knowledge  yes         yes
  time-based code            possession yes         no
  SMS code                   possession no          no
  hardware security key      possession yes         no
  recovery code              knowledge  yes         yes
factors in the knowledge class: 2 — if the second factor is chosen from the first one's class, there is no gain.

recovery code attempts
  valid code         -> accept
  same code again    -> reject: code was already used
  not in the list    -> reject: invalid code
remaining usable codes: 2 / 3
```

#### How a Time-Based Code Is Verified

The output's first table shows that the code depends on the time slice: every moment
within the same slice gives the same code, and the code changes when the slice
changes. What produces the code is the shared secret and the slice number; nothing
travels over the network between server and client.

The second table measures clock skew. When the user's clock is forty-five seconds
behind, verification fails with a zero window; it is accepted within a two-slice
window, and the number of slices of difference is reported.

The window's cost shows up in the table: every extra slice increases the number of
accepted codes and widens the guessing space. A one-slice window (three codes total)
is a common balance; keeping the server's clock accurate is preferred over a wider
window.

#### The Same Code Cannot Be Used Twice

The third section shows the reuse defense. The code is valid for the length of its
validity slice — about half a minute; accepting the same code a second time within
that span would let someone intercepting it replay the code.

The solution is to record the accepted code's slice on a per-user basis. The record's
lifetime matches the window; old records are cleared out. This is the same pattern as
the reuse list in the Enterprise Federation lesson — the same solution in a different
context.

Attempt limiting is a separate layer: a six-digit code carries a million
possibilities, and can be guessed within the window if unlimited attempts are
allowed. Consecutive failed attempts are counted at the account level, and once the
threshold is exceeded, the second factor is temporarily locked.

#### No Gain If the Class Is the Same

The fourth table compares factors by three criteria: class, offline capability, and
transferability. The last column is the most distinguishing one. A password and a
recovery code are **transferable**: the user can unknowingly tell them to someone
else, type them into a page. A time-based code also looks transferable, but its
thirty-second lifetime makes transferring it hard; a hardware key, on the other hand,
cannot be transferred, because the signing operation happens inside the device.

The table's last line states a rule with a number: two of the factors on the list are
in the knowledge class. If the second factor is chosen from the first one's class —
putting a "secret question" alongside the password — the attack surface does not
change, because both are compromised through the same path.

The code sent by SMS occupies a separate position: it is in the possession class, but
it does not work offline and its delivery depends on a third party. It is better than
having no second factor at all; where available, an app-based code or a hardware key
is preferred.

#### Recovery Codes Are the Account's Spare Key

The last section runs the recovery codes. When the second factor is lost — the phone
is replaced, the device breaks — the user needs a way back into the account. Recovery
codes open this path, and precisely for that reason are **the account's weakest
link**.

Three rules balance this. The codes are single-use: a used code is not accepted
again, and the number of remaining codes is shown to the user. The codes are stored
as a hash; they cannot be used directly in a database leak. And a notification goes
to the user when a code is used — the recovery flow is the most likely path to
account takeover, and it must not operate silently.

Considered together with the reset flow built in the Account Recovery lesson, the
criterion is this: the paths back into the account must not be weaker than the login
path. If resetting the password can bypass the second factor, there is no second
factor.

#### Summary

- Multi-factor authentication's gain comes not from the number of proofs but from the
  proofs being from different classes and independent; in the model, two factors
  fall into the knowledge class.
- The time-based code is derived from the shared secret and the time slice; the
  clock skew window increases the number of accepted codes, so it is kept narrow.
- The accepted code's slice is recorded; the same code is not accepted a second time,
  and the number of attempts is limited separately.
- An SMS code is in the possession class but does not work offline and its delivery
  depends on a third party; an app-based code or a hardware key is preferred.
- Recovery codes are single-use, stored as a hash, and produce a notification when
  used; the path back into the account cannot be weaker than the login path.

#### Next Step

Throughout this topic, the server assumed it could verify something the user knew or
possessed. How the password itself is stored, which account that verification is
checked against, and how well the storage method holds up in a leak have not yet been
asked. The next topic goes underneath this assumption and ties password storage to
measurable parameters.

## Credential Security

Storing a password with a slow hashing algorithm and choosing its parameters, the separate jobs of salt and pepper, password policies that do not break usability, the attack surface of the reset flow, and protecting the session from sign-in through to termination.

### Password Storage

Source: https://academia.sh/en/courses/authentication-and-authorization/password-storage

Storing a password irreversibly: the work and memory factors of slow hashing algorithms, choosing parameters against a time budget, the shape of the stored record, and comparing it in constant time.

The previous topic covered how identity gets verified: the basic scheme, sessions,
tokens, and multi-factor authentication were all the server's answers to one
question — does this request really come from that person? A single assumption sat
under all of them: the server can verify something the user knows. This topic looks at
that assumption itself — what does the server store to verify the password?

In the library lending service, members and staff sign in with a password. The record on
the server can end up in someone else's hands one day — a backup file, a botched export,
a compromised reporting account. That is the measure of password storage: when the
record leaks, how much information leaks with it? This lesson is about lowering the cost
of that moment.

#### Verifiable But Irreversible

The server does not need to know the password to verify it — only to produce the same
result from what the user sends. This is why a password is stored not as plain text but
as a **one-way hash**: computing the output from the input is easy, and computing the
input back from the output is computationally infeasible.

The hash function concept was introduced in the Data Structures course, where the goal
was a bucket number and the measure was speed. In password storage, the measure
reverses: the faster a hash computes, the faster a candidate password can be tried
against a leaked record. A fast verification helps the attacker; a slow one only delays a
login by a few tens of milliseconds. This asymmetry is password storage's one design
lever.

For this reason, a password uses not a general-purpose hash function but a **key
derivation function**. These carry two adjustable factors: the **work factor**, how many
rounds the computation takes, and the **memory factor**, how much memory it holds. A
design that forces memory use is called **memory hardness**, meant to make spreading the
computation across many small units of work expensive. `scrypt` and `Argon2` are two
members of this class; this lesson uses `scrypt`, built into the `node` runtime.

#### Measuring the Cost

The difference between the two approaches is measured, not argued. The script below
compares, on one machine, the verification time of a single-pass hash against `scrypt` at
different work factors. The last column is verifications per second on a single core.

```bash
cat > measurement.mjs <<'EOF'
import { createHash, scryptSync, randomBytes } from "node:crypto";

const password = "shore-branch-2026";
const salt = randomBytes(16);

function duration(count, task) {
  task();                                       // warm-up
  const start = process.hrtime.bigint();
  for (let i = 0; i < count; i++) task();
  return Number(process.hrtime.bigint() - start) / count / 1e6;   // milliseconds
}

const rows = [
  ["sha-256 (single pass)", "-", duration(500, () =>
    createHash("sha256").update(salt).update(password).digest())],
];

for (const N of [2 ** 12, 2 ** 14, 2 ** 16, 2 ** 17]) {
  const r = 8, p = 1;
  rows.push([
    `scrypt N=2^${Math.log2(N)} r=${r} p=${p}`,
    `${((128 * r * N) / 1024 / 1024).toFixed(0)} MiB`,
    duration(3, () => scryptSync(password, salt, 32, { N, r, p, maxmem: 512 * 1024 * 1024 })),
  ]);
}

console.log("algorithm              | memory  | verify     | logins/s per core");
console.log("-----------------------|---------|------------|------------------------");
for (const [name, memory, ms] of rows) {
  console.log(
    name.padEnd(22) + " | " + memory.padStart(7) + " | " +
    (ms < 1 ? ms.toFixed(4) : ms.toFixed(1)).padStart(7) + " ms | " +
    (1000 / ms).toFixed(0).padStart(22)
  );
}
EOF
node measurement.mjs
```

```text
algorithm              | memory  | verify     | logins/s per core
-----------------------|---------|------------|------------------------
sha-256 (single pass)  |       - |  0.0013 ms |                 781504
scrypt N=2^12 r=8 p=1  |   4 MiB |     4.3 ms |                    231
scrypt N=2^14 r=8 p=1  |  16 MiB |    17.6 ms |                     57
scrypt N=2^16 r=8 p=1  |  64 MiB |    79.4 ms |                     13
scrypt N=2^17 r=8 p=1  | 128 MiB |   159.5 ms |                      6
```

Absolute times change with the machine and runtime version; these were taken on one
desktop core with `node` 24. What does not change is the order of magnitude: roughly a
fourteen-thousand-fold gap between the single-pass hash and `N=2^14`. That gap is
directly proportional to an attacker's cost against a leaked record — hundreds of
thousands of candidates per second with the fast hash, only a few dozen with the slow
one.

The memory column shows the second defense. At `N=2^16`, each verification holds 64
MiB — trivial for one server, decisive for a party spreading the same work across
thousands of parallel units. Raising the work factor slows the computation; raising the
memory factor makes it impossible to spread across cheap hardware.

#### Choosing the Work Factor

Parameters are not chosen by "bigger is always better." Verification is the server's own
work, and too large a factor lets login requests lock it up. The choice has two
constraints: the time budget per login and the memory held at peak load.

The script below applies both together: a 100-millisecond budget per login, a peak load
of twenty logins per second. The memory column is the combined consumption of every
verification running at once at that factor.

```bash
cat > calibrate.mjs <<'EOF'
import { scryptSync, randomBytes } from "node:crypto";

const BUDGET_MS = 100;                    // time set aside per login
const CONCURRENT_LOGINS = 20;             // expected peak logins per second
const salt = randomBytes(16), r = 8, p = 1;

function measure(N) {
  const settings = { N, r, p, maxmem: 512 * 1024 * 1024 };
  scryptSync("measurement", salt, 32, settings);
  const start = process.hrtime.bigint();
  scryptSync("measurement", salt, 32, settings);
  return Number(process.hrtime.bigint() - start) / 1e6;
}

console.log(`budget: ${BUDGET_MS} ms/login, peak load: ${CONCURRENT_LOGINS} logins/s`);
console.log("N     | verify    | memory at peak    | budget");
console.log("------|-----------|-------------------|-------");
let chosen = null;
for (const exp of [12, 13, 14, 15, 16, 17]) {
  const N = 2 ** exp, ms = measure(N);
  const memory = (128 * r * N * Math.ceil(ms / 1000 * CONCURRENT_LOGINS)) / 1024 / 1024;
  const status = ms <= BUDGET_MS ? "eligible" : "exceeded";
  if (ms <= BUDGET_MS) chosen = exp;
  console.log(`2^${exp}  | ${ms.toFixed(1).padStart(6)} ms | ${memory.toFixed(0).padStart(13)} MiB | ${status}`);
}
console.log("chosen: N=2^" + chosen);
EOF
node calibrate.mjs
```

```text
budget: 100 ms/login, peak load: 20 logins/s
N     | verify    | memory at peak    | budget
------|-----------|-------------------|-------
2^12  |    5.3 ms |             4 MiB | eligible
2^13  |    8.7 ms |             8 MiB | eligible
2^14  |   18.2 ms |            16 MiB | eligible
2^15  |   37.7 ms |            32 MiB | eligible
2^16  |   77.8 ms |           128 MiB | eligible
2^17  |  162.7 ms |           512 MiB | exceeded
chosen: N=2^16
```

The numbers the script produces are specific to the machine; the decision procedure is
not. The rule: pick the largest factor that stays under budget, and read it from the
deployment configuration rather than hardcoding it. Re-measure whenever the server
hardware changes.

The budget is itself a decision. Signing in is an operation users expect to wait for, and
a hundred milliseconds passes unnoticed. The computation is not repeated on every
request — only at login, after which a session or token takes over. A setup that repeats
verification on every API call makes this budget unworkable; that is why a password hash
has no place verifying an API key.

#### The Shape of the Stored Record

The record holds more than the hash: the algorithm that produced it and its parameters
are stored alongside, or upgraded parameters make old records unverifiable. The standard
shape joins these fields into one string with a separator.

```bash
cat > password.mjs <<'EOF'
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";

const SETTINGS = { N: 2 ** 14, r: 8, p: 1, length: 32 };

function store(password) {
  const salt = randomBytes(16);
  const digest = scryptSync(password.normalize("NFKC"), salt, SETTINGS.length, SETTINGS);
  return ["scrypt", SETTINGS.N, SETTINGS.r, SETTINGS.p,
          salt.toString("base64"), digest.toString("base64")].join("$");
}

function verify(password, record) {
  const [alg, N, r, p, salt64, digest64] = record.split("$");
  if (alg !== "scrypt") return false;
  const expected = Buffer.from(digest64, "base64");
  const computed = scryptSync(password.normalize("NFKC"), Buffer.from(salt64, "base64"),
    expected.length, { N: +N, r: +r, p: +p });
  return timingSafeEqual(computed, expected);
}

const record = store("shore-branch-2026");
console.log("stored record  :", record);
console.log("length         :", record.length, "characters");
console.log("correct password:", verify("shore-branch-2026", record));
console.log("wrong password :", verify("shore-branch-2025", record));
console.log("second record  :", store("shore-branch-2026").slice(0, 24) + "...");
EOF
node password.mjs
```

```text
stored record  : scrypt$16384$8$1$/kFvW5D9IBMpOV7Sn9t0YQ==$JwrU7KOCzQiHUKCE8+Uazs9zrp1RhJgQVcYqhINzCsQ=
length         : 86 characters
correct password: true
wrong password : false
second record  : scrypt$16384$8$1$QVqPrxj...
```

The randomly generated values change on every run; the record's length and field layout
do not. Three pieces of information travel together — algorithm, parameters, random
value — which is why the database column is defined at a width that leaves room for
future formats, not a fixed length.

The last line points to the next lesson: the same password stored twice produces
different records. The `randomBytes` call is why, and the reason is next lesson's
subject.

The `normalize("NFKC")` call rests on a separate justification: text that looks identical
can be encoded as different byte sequences, and if registration produces one form while
sign-in produces another, verification fails — to the user, "I typed the right password
and it is not accepted." Normalization runs the same way on **both** the storage and
verification paths; applying it to only one moves the problem instead of solving it.

#### The Time a Comparison Takes

Verification's last step compares two byte sequences, and how that step is written
matters. A comparison that returns early stops at the first differing byte — so its
running time gives away how far the candidate overlapped with the correct value.

```bash
cat > comparison.mjs <<'EOF'
import { randomBytes, timingSafeEqual } from "node:crypto";

const correct = randomBytes(32);

function earlyReturn(a, b) {                    // stops at the first difference
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
  return true;
}

function measure(compare, candidate) {
  for (let i = 0; i < 2000; i++) compare(correct, candidate);      // warm-up
  const start = process.hrtime.bigint();
  for (let i = 0; i < 200000; i++) compare(correct, candidate);
  return Number(process.hrtime.bigint() - start) / 200000;       // nanoseconds
}

const firstByteDiff = Buffer.from(correct); firstByteDiff[0] ^= 1;
const lastByteDiff = Buffer.from(correct); lastByteDiff[31] ^= 1;

console.log("comparison       |  diff at 1st byte | diff at last byte | ratio");
console.log("------------------|--------------------|--------------------|------");
for (const [name, fn] of [["early return", earlyReturn], ["timingSafeEqual", timingSafeEqual]]) {
  const a = measure(fn, firstByteDiff), b = measure(fn, lastByteDiff);
  console.log(
    name.padEnd(17) + " | " + (a.toFixed(1) + " ns").padStart(18) + " | " +
    (b.toFixed(1) + " ns").padStart(18) + " | " + (b / a).toFixed(2).padStart(5)
  );
}
EOF
node comparison.mjs
```

```text
comparison       |  diff at 1st byte | diff at last byte | ratio
------------------|--------------------|--------------------|------
early return      |             3.4 ns |            22.4 ns |  6.60
timingSafeEqual   |            32.9 ns |            30.6 ns |  0.93
```

Nanosecond values change with the machine; the ratio column does not. With the
early-return comparison, a candidate diverging at the last byte takes six times as long
as one diverging at the first — time as a signal for overlap length. `timingSafeEqual`
processes every byte regardless, so its ratio stays near one.

For the password itself this signal is hard to exploit remotely — tens of milliseconds of
hashing precede the comparison, and network latency buries it in noise. But the same
pattern appears where there is no hash: session identifiers, reset tokens, API keys,
signature checks. So the rule holds everywhere — every comparison against a secret is
constant-time. The Client-Side Security topic set the same rule for comparing request
tokens; the server side applies it too.

#### Upgrading Parameters

Today's factor grows insufficient as hardware gets cheaper. Because the record carries
its own parameters, the upgrade runs without interruption: the user's submitted password
is available at login, and on successful verification the record is regenerated with new
parameters and written back — one extra write, invisible to the user.

The limit: accounts that never sign in keep their old parameters. For those, a second
path applies the new algorithm on top of the old hash and marks the record as
two-layered. Carrying an algorithm field in the record format is what makes this
transition possible without losing records.

A third path is not accepted — asking users to resubmit passwords in plain text. A system
that can ask for a password again can hold onto it somewhere; this whole lesson
establishes the opposite.

#### Summary

- A password is stored irreversibly; the server needs to produce the same result, not
  know the password.
- General-purpose hash functions do not fit passwords; key derivation functions with
  adjustable work and memory factors do.
- The work factor is the largest value that stays under the per-login time budget;
  memory at peak load is the second constraint.
- The record carries the algorithm and parameters alongside the hash; an upgrade
  regenerates the record on a successful login.
- A comparison against a secret is constant-time; an early-return comparison's running
  time gives away overlap length.

#### Next Step

This lesson generated a random value with `randomBytes` for every record, and two
records of the same password came out different. That value's name and job are not
explained yet. The next lesson shows what the salt is for, why an unsalted setup gives
two members with the same password identical records, and how much a single leak reveals
about how many users. It also covers where the pepper — a second value separate from the
salt — sits, not in the database but in the application's configuration, and what that
separation changes at the moment of a leak.

### Salt and Pepper

Source: https://academia.sh/en/courses/authentication-and-authorization/salt-and-pepper

What a per-record random salt provides against precomputed tables, the information an unsalted setup leaks, the separate jobs of salt and pepper, and what keeping the pepper outside the database changes at the moment of a leak.

The previous lesson generated a sixteen-byte value with `randomBytes` for every record,
and two records of the same password came out different. That value's name is **salt**,
and its job is not to hide the hash but to break equality between records. This lesson
covers exactly what the salt prevents, what an unsalted setup gives away, and where a
second value separate from the salt — the pepper — sits.

The question follows from where the last lesson left off: a slow hash was chosen,
parameters were tuned by measurement, the record format was set. If the records leak,
does each one have to be attacked separately, or does a single piece of work serve them
all at once? The salt is what separates these two outcomes.

#### The Salt's Job

A hash function is deterministic: the same input always produces the same output. This
property is what makes verification possible, but used alone it has an unwanted
consequence — two members who pick the same password end up with identical records.

The salt is a second input to the hash computation: generated separately for each
record, not secret, stored next to the hash. The result — the same password with
different salts produces different hashes. Verification reads the salt from the record,
combines it with the submitted password, and compares the result; the flow does not
change, only the relationship between records does.

#### What an Unsalted Setup Reveals

The script below builds the library's member table with two columns: unsalted and
salted hashes of the same passwords. The data is local and for illustration only; three
of eight members share one password, and two more pairs share two other passwords.

```bash
cat > salt.mjs <<'EOF'
import { createHash, randomBytes, scryptSync } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE member(code TEXT PRIMARY KEY, branch TEXT, unsalted TEXT, salted TEXT)`);

// Local sample data; only to show the setup difference.
const members = [
  ["U-1001", "S-01", "sea-shell-7"],
  ["U-1002", "S-01", "library"],
  ["U-1003", "S-02", "sea-shell-7"],
  ["U-1004", "S-02", "library"],
  ["U-1005", "S-01", "summer-2026-reading"],
  ["U-1006", "S-03", "library"],
  ["U-1007", "S-03", "plane-tree"],
  ["U-1008", "S-02", "summer-2026-reading"],
];

const settings = { N: 2 ** 12, r: 8, p: 1 };
const insert = db.prepare("INSERT INTO member VALUES (?,?,?,?)");
for (const [code, branch, password] of members) {
  const unsalted = createHash("sha256").update(password).digest("base64");
  const salt = randomBytes(16);
  const salted = salt.toString("base64") + "$" +
    scryptSync(password, salt, 32, settings).toString("base64");
  insert.run(code, branch, unsalted, salted);
}

const print = (title, rows) => {
  console.log(title);
  for (const r of rows) console.log("  " + r);
};

print("unsalted records (first 12 characters):",
  db.prepare("SELECT code, substr(unsalted,1,12) k FROM member ORDER BY code").all()
    .map((s) => `${s.code}  ${s.k}`));

print("\nmembers sharing the same hash:",
  db.prepare(`SELECT substr(unsalted,1,12) k, COUNT(*) count, group_concat(code,' ') who
              FROM member GROUP BY unsalted HAVING COUNT(*) > 1`).all()
    .map((s) => `${s.k}  ${s.count} members: ${s.who}`));

print("\nsame grouping on the salted records:",
  db.prepare(`SELECT COUNT(*) groups FROM
              (SELECT salted FROM member GROUP BY salted HAVING COUNT(*) > 1)`).all()
    .map((s) => `matching groups: ${s.groups}`));
EOF
node salt.mjs
```

```text
unsalted records (first 12 characters):
  U-1001  d7y3WIa1ljec
  U-1002  txjxNU9yRzEu
  U-1003  d7y3WIa1ljec
  U-1004  txjxNU9yRzEu
  U-1005  Cp++5Jrvtvu7
  U-1006  txjxNU9yRzEu
  U-1007  gNrOL2tZWWn6
  U-1008  Cp++5Jrvtvu7

members sharing the same hash:
  Cp++5Jrvtvu7  2 members: U-1005 U-1008
  d7y3WIa1ljec  2 members: U-1001 U-1003
  txjxNU9yRzEu  3 members: U-1002 U-1004 U-1006

same grouping on the salted records:
  matching groups: 0
```

A single `GROUP BY` query produced three pieces of information without decoding a
single password.

First, which members share a password — U-1002, U-1004, and U-1006 picked the same
string; these three accounts now act as one, since anything learned about one holds for
the other two.

Second, which group is largest: the three-member group points to the most common choice
in this table, the choice sitting at the top of any candidate list.

Third, whether a manager's or staff member's password matches a member's — a link
between accounts, visible to anyone looking at the table.

The same query against the salted column returns zero groups. The passwords did not
change; the records no longer inform on each other. Salt does not strengthen a
password — it erases the visible relationship between passwords.

#### The Cost of a Precomputed Table

The salt's second, better-known effect works against **precomputed tables**. Once
candidate passwords' hashes are computed and stored, attacking a leaked unsalted record
reduces to a lookup — the computation is already done. A space-compressed form of these
tables is called a rainbow table; for the defense, the two amount to the same thing.

Salt breaks this economy. Because the salt differs per record, a precomputed table
matches no record; the work has to be redone from scratch for every one. The script
below turns this difference into numbers using the times measured in the previous
lesson.

```bash
cat > cost.mjs <<'EOF'
const CANDIDATES = 1_000_000;    // number of candidates in the precomputed list
const USERS = 100_000;           // number of leaked records
const FAST_MS = 0.0013;          // measured: sha-256 single pass
const SLOW_MS = 18.2;            // measured: scrypt N=2^14

function format(ms) {
  const s = ms / 1000;
  if (s < 3600) return `${s.toFixed(1)} seconds`;
  const h = s / 3600;
  if (h < 24 * 365) return `${h.toFixed(1)} hours`;
  return `${(h / 24 / 365).toFixed(1)} years`;
}

const setups = [
  ["unsalted + fast hash", CANDIDATES * FAST_MS, "list built once, fits every record"],
  ["unsalted + slow hash", CANDIDATES * SLOW_MS, "list built once, fits every record"],
  ["salted   + fast hash", CANDIDATES * USERS * FAST_MS, "work redone per record"],
  ["salted   + slow hash", CANDIDATES * USERS * SLOW_MS, "work redone per record"],
];

console.log(`candidates: ${CANDIDATES.toLocaleString("en-US")}   leaked records: ${USERS.toLocaleString("en-US")}`);
console.log("setup".padEnd(20) + " | work on one core | note");
console.log("-".repeat(21) + "|" + "-".repeat(19) + "|" + "-".repeat(39));
for (const [name, ms, note] of setups) {
  console.log(name.padEnd(20) + " | " + format(ms).padStart(17) + " | " + note);
}
EOF
node cost.mjs
```

```text
candidates: 1,000,000   leaked records: 100,000
setup                | work on one core | note
---------------------|-------------------|---------------------------------------
unsalted + fast hash |       1.3 seconds | list built once, fits every record
unsalted + slow hash |         5.1 hours | list built once, fits every record
salted   + fast hash |        36.1 hours | work redone per record
salted   + slow hash |        57.7 years | work redone per record
```

The table shows how the two defenses multiply each other. A slow hash alone is beaten
in five hours in an unsalted setup, because the work is done once and applied to all
hundred thousand records. Salt alone, with a fast hash, pushes the cost to thirty-six
hours. Combined, the number reaches fifty-seven years.

These absolute numbers are not a guess but a product of measured times, and they change
with the hardware. What does not change is the shape of the multiplication: salt
multiplies the work by the record count; a slow hash multiplies it by unit cost. The two
solve separate problems, and neither substitutes for the other.

#### The Rules of Salt

A correct salt setup is defined by four rules.

**Generated separately per record.** Using a single salt application-wide leaves the
unsalted setup's equality problem exactly as it was — two records with the same password
still produce the same hash.

**Generated randomly.** A salt derived from a member code, an email address, or the
registration time is predictable, which makes precomputation possible again. The source
is a cryptographic random number generator.

**Long enough.** Sixteen bytes is the standard choice. The measure is keeping the
probability of two salts colliding negligible; a short salt lets different records land
on the same salt, bringing equality back between them.

**Not secret.** The salt sits next to the hash, in the same row — it does not need to be
kept secret; its job is variety, not secrecy. Treating it as secret is the common
confusion that leads to using the pepper in its place.

The last rule also governs the password-change flow: when the password changes, the
salt is renewed too. Keeping the old salt would leave the new hash comparable to the old
one.

#### Pepper: The Value Outside the Database

**Pepper** differs from salt on three points: shared by all records, secret, and never in
the database. It lives in the application's configuration — an environment variable or a
secrets store. The Server-Side Fundamentals course established separating secrets from
code and data paths; pepper is that separation applied to passwords.

It is implemented as an **HMAC**: the slow hash's output is hashed once more, keyed with
the pepper. What gets written to the record is this final output.

```bash
cat > pepper.mjs <<'EOF'
import { scryptSync, randomBytes, createHmac, timingSafeEqual } from "node:crypto";

const SETTINGS = { N: 2 ** 12, r: 8, p: 1 };
const PEPPERS = { v1: process.env.PEPPER_V1 ?? "" };     // read from an environment variable

function store(password, version = "v1") {
  const salt = randomBytes(16);
  const slow = scryptSync(password, salt, 32, SETTINGS);
  const digest = createHmac("sha256", PEPPERS[version]).update(slow).digest();
  return [version, salt.toString("base64"), digest.toString("base64")].join("$");
}

function verify(password, record) {
  const [version, salt64, digest64] = record.split("$");
  const pepper = PEPPERS[version];
  if (pepper === undefined) return "pepper not found";
  const slow = scryptSync(password, Buffer.from(salt64, "base64"), 32, SETTINGS);
  const computed = createHmac("sha256", pepper).update(slow).digest();
  return timingSafeEqual(computed, Buffer.from(digest64, "base64"));
}

const record = store("sea-shell-7");
console.log("written to the database      :", record);
console.log("is the pepper in the record  :", record.includes(PEPPERS.v1));
console.log("right pepper, right password :", verify("sea-shell-7", record));
console.log("right pepper, wrong password :", verify("sea-shell-8", record));

PEPPERS.v1 = "different-value";                        // pepper is unknown
console.log("wrong pepper, right password :", verify("sea-shell-7", record));
EOF
PEPPER_V1='example-app-secret' node pepper.mjs
```

```text
written to the database      : v1$MC4YxndY4mseXeOMqKekhg==$uoLNUQqNsMoW0YdPUR0xtC1IQ7c/uPATFpw9xbZPx3Y=
is the pepper in the record  : false
right pepper, right password : true
right pepper, wrong password : false
wrong pepper, right password : false
```

The salt field changes on every run; the meaning of the lines does not. The second line
confirms the pepper never enters the record in any form; the last shows its function —
even with the correct password, verification comes back negative when the pepper is
unknown.

That is what it means at the moment of a leak: if only the database leaks — a backup
file, a read-only reporting connection, a botched export — trying candidates against the
records gets nowhere, because the key needed for the last step of the computation is not
there. The pepper adds exactly this one scenario on top of the salt and the slow hash.

#### Pepper's Limits and Rotation

The pepper's protection has to stay narrowly defined. If the application server is also
compromised, the pepper goes with it, and the setup falls back to the level of one
without a pepper — which is why it does not replace the salt or the slow hash, only sits
on top of both.

The second limit is operational: if the pepper is lost, no user can sign in and there is
no way back from the records — the only way out is resetting every password. This makes
it the most carefully backed-up piece of the application configuration, and it never
appears in logs, error messages, or diagnostic output.

Third is rotation. The record's `v1` field exists for this: when a new pepper is added,
the old version stays in the configuration for verification, and the record regenerates
with the new version on every successful login — the same procedure as the previous
lesson's parameter upgrade, since in both cases the record carries its own production
conditions.

#### Summary

- Salt is a value generated per record, not secret, and stored next to the hash; its job
  is to break equality between records.
- In an unsalted setup, a single grouping query reveals the accounts sharing a password
  and the most common choice, without decoding any password.
- Salt multiplies the cost of a precomputed table by the record count; a slow hash raises
  the unit cost. The two solve separate problems.
- Salt is generated per record, randomly, and at sufficient length; it is renewed when
  the password changes.
- Pepper is a key shared by all records, secret, and kept outside the database; it
  protects only the case where the database alone leaks, and it is rotated through a
  version field.

#### Next Step

Every defense so far sat on the server side and never touched the password the user
chose. Yet what the grouping query revealed — three members picking the same string —
cannot be fixed by any server-side parameter. The next lesson turns to password
policies: it computes, through a model, how length and complexity rules reshape user
behavior, and shows how to check against a breached password list without exposing the
list or the password.

### Password Policies

Source: https://academia.sh/en/courses/authentication-and-authorization/password-policies

How complexity rules narrow user behavior, the real difference a periodic change rule adds, checking against a breached password list, and a hash-prefix query that never exposes the password.

The previous lesson's grouping query showed one thing: three of eight members had
picked the same string, `library`. Salt keeps these three records from resembling each
other, and a slow hash makes testing each one expensive — but neither changes the
choice itself. Where server-side defenses end is where password policy begins.

A policy is the rule that tells the user which password gets accepted. It looks easy to
write, but it is where usability and security collide most directly: the stricter the
rule, the more the user follows a predictable path that satisfies it with least effort.
This lesson measures which rule actually works.

#### The Space a Rule Promises and the Pattern Users Produce

A password policy's value is not measured by the **search space** it theoretically opens
up. The measure is the space of the pattern users actually produce in response to the
rule.

Take the rule "at least eight characters, uppercase, lowercase, a digit, and a symbol
required." It specifies eight positions out of a set of ninety-four characters. The
user's lowest-effort way to satisfy it is predictable: take a known word, capitalize its
first letter, append one or two digits and a symbol. The resulting pattern's space is
far smaller than the space the rule promises.

The script below computes this gap for four policies. The pattern spaces are a model;
the assumptions are written explicitly into the code and can be changed and recomputed.

```bash
cat > policy.mjs <<'EOF'
const bit = (n) => Math.log2(n);

const DICTIONARY = 20000;      // base word count the user picks from (model)
const policies = [
  {
    name: "min 8 chars, four character types",
    theoretical: 94 ** 8,
    // Shortest path that satisfies the rule: word + capital first letter + 1-2 digits + 1 symbol
    pattern: DICTIONARY * 1 * 110 * 10,
    source: "word + Capital letter + digit + symbol",
  },
  { name: "min 12 chars, no rule", theoretical: 26 ** 12,
    pattern: DICTIONARY * DICTIONARY * 100, source: "two words + digit" },
  { name: "min 16 chars, no rule", theoretical: 26 ** 16,
    pattern: DICTIONARY ** 3 * 10, source: "three words + digit" },
  { name: "4-word passphrase (2048-word list)", theoretical: 2048 ** 4,
    pattern: 2048 ** 4, source: "random pick, no pattern" },
];

console.log("policy                             | theory   | pattern  | loss  | source of the pattern");
console.log("-----------------------------------|----------|----------|-------|----------------------------");
for (const p of policies) {
  console.log(
    p.name.padEnd(34) + " | " +
    (bit(p.theoretical).toFixed(1) + " bit").padStart(8) + " | " +
    (bit(p.pattern).toFixed(1) + " bit").padStart(8) + " | " +
    (bit(p.theoretical / p.pattern).toFixed(1)).padStart(5) + " | " + p.source
  );
}

console.log("\nchanging every 90 days, if the user advances a trailing counter:");
for (const period of [1, 4, 8, 20]) {
  const space = DICTIONARY * 110 * 10 * period;
  console.log(`  after ${String(period).padStart(2)} periods: ${bit(space).toFixed(1)} bit ` +
    `(difference from the first period: ${(bit(space) - bit(DICTIONARY * 110 * 10)).toFixed(1)} bit)`);
}
EOF
node policy.mjs
```

```text
policy                             | theory   | pattern  | loss  | source of the pattern
-----------------------------------|----------|----------|-------|----------------------------
min 8 chars, four character types  | 52.4 bit | 24.4 bit |  28.0 | word + Capital letter + digit + symbol
min 12 chars, no rule              | 56.4 bit | 35.2 bit |  21.2 | two words + digit
min 16 chars, no rule              | 75.2 bit | 46.2 bit |  29.0 | three words + digit
4-word passphrase (2048-word list) | 44.0 bit | 44.0 bit |   0.0 | random pick, no pattern

changing every 90 days, if the user advances a trailing counter:
  after  1 periods: 24.4 bit (difference from the first period: 0.0 bit)
  after  4 periods: 26.4 bit (difference from the first period: 2.0 bit)
  after  8 periods: 27.4 bit (difference from the first period: 3.0 bit)
  after 20 periods: 28.7 bit (difference from the first period: 4.3 bit)
```

The first row shows the policy's most common form: the rule promises fifty-two bits, the
user's pattern delivers twenty-four. The twenty-eight-bit gap is a direct result of the
direction the rule pushes the user — forced complexity gets added at the cheapest point,
the end.

The second and third rows show what length does. With no character-class rule at all,
raising the minimum length alone pushes the pattern space from twenty-four bits to
thirty-five, then forty-six. Length forces more choices; complexity only forces
decoration of the same choice.

The fourth row shows the limit: when the choice goes to a random generator instead of the
user, no pattern is left, and the theoretical space equals the real one — forty-four
bits, twenty more than the first row, and easier to remember besides.

#### The Periodic Change Rule

The output's second part is the balance sheet of a common operational rule. Requiring a
password change every ninety days adds a total of four and a half bits over twenty
periods, if the user just advances a trailing counter — the return on the same user
typing the same password twenty times.

The other side of the ledger holds a cost that cannot be measured but can be
observed: reset requests rising at the start of every period, written-note behavior, and
a predictable trailing counter. The one defensible form is conditional — a change
required only on a sign the password has leaked. Calendar-driven mandatory change does
about as much harm as the bits it measurably adds.

#### Checking Against a Breached Password List

The pattern table's real conclusion: steering the user's choice with rules is a weak
tool; rejecting the choice outright is the strong one. A password already known to have
leaked belongs in every candidate list no matter how many rules it satisfies, and it
should be rejected.

Applying this check runs into one problem: the list is large, and sending the user's
chosen password to whoever holds it is unacceptable. The solution is querying on a
**prefix** of the password's hash instead of the password itself. The server returns
every record matching that prefix, and the client decides in its own memory whether the
password is in that set. All the server learns is the prefix; which record was actually
being searched for stays ambiguous — an ambiguity called **k-anonymity**, where `k` is
the number of records in the returned set.

Prefix length is a trade-off. A short prefix returns a bigger bucket — more privacy,
more data transferred. A long prefix does the opposite. The script below measures this
trade-off on a local list.

```bash
cat > prefix.mjs <<'EOF'
import { createHash } from "node:crypto";

const root = ["library","book","reading","branch","sea","plane","summer","winter","member","loan",
              "desk","shelf","card","fine","return","catalog","hall","pen","ledger","note"];

function* list(n = 200000) {                     // local, generated sample list
  yield* ["library", "shore-branch", "loan123"];
  for (let i = 0; i < n; i++) {
    yield `${root[i % 20]}-${root[Math.floor(i / 20) % 20]}-${Math.floor(i / 400) % 500}`;
  }
}

const hashes = [...list()].map((p) =>
  createHash("sha1").update(p).digest("hex").toUpperCase());
console.log(`local list: ${hashes.length.toLocaleString("en-US")} records`);

console.log("\nprefix | bucket count | avg bucket | largest bucket | what the server sees");
console.log("-------|--------------|------------|-----------------|----------------------");
for (const length of [2, 3, 4, 5]) {
  const bucket = new Map();
  for (const h of hashes) {
    const p = h.slice(0, length);
    bucket.set(p, (bucket.get(p) ?? 0) + 1);
  }
  const sizes = [...bucket.values()];
  const avg = sizes.reduce((a, b) => a + b, 0) / sizes.length;
  const largest = sizes.reduce((a, b) => (b > a ? b : a), 0);
  console.log(
    ` ${length}   | ${String(bucket.size).padStart(12)} | ${avg.toFixed(1).padStart(10)} | ` +
    `${String(largest).padStart(15)} | ${(length * 4)} bit / 160 bit`
  );
}
EOF
node prefix.mjs
```

```text
local list: 200,003 records

prefix | bucket count | avg bucket | largest bucket | what the server sees
-------|--------------|------------|-----------------|----------------------
 2   |          256 |      781.3 |             844 | 8 bit / 160 bit
 3   |         4096 |       48.8 |              78 | 12 bit / 160 bit
 4   |        62454 |        3.2 |              12 | 16 bit / 160 bit
 5   |       182069 |        1.1 |               5 | 20 bit / 160 bit
```

The table shows that prefix length has to be chosen against list size. In this
two-hundred-thousand-record list, a four-character prefix returns about three records on
average — a bucket size where k-anonymity stops meaning anything. Three characters gives
an average bucket of about forty-nine records, and the server sees only twelve of the
hash's hundred sixty bits. When the list grows to billions of records, the same bucket
size needs a five-character prefix — the rule is choosing prefix length so the bucket
size holds at the target `k`.

The hash function chosen here is a separate matter. This computation distributes
records across prefix buckets, not stores a password; speed is a requirement here, not a
flaw. The slow-hash rule from the previous two lessons applies to the storage path, not
to this index key.

#### Running the Query

The block below stands up a local range service and checks two passwords: `library`,
shared by members in the previous lesson, and `sea-shell-7`, U-1001's choice. The only
thing that goes out in the request is the three-character prefix.

```bash
cat > range-server.mjs <<'EOF'
import { createServer } from "node:http";
import { createHash } from "node:crypto";

const root = ["library","book","reading","branch","sea","plane","summer","winter","member","loan",
              "desk","shelf","card","fine","return","catalog","hall","pen","ledger","note"];
function* list(n = 200000) {
  yield* ["library", "shore-branch", "loan123"];
  for (let i = 0; i < n; i++) {
    yield `${root[i % 20]}-${root[Math.floor(i / 20) % 20]}-${Math.floor(i / 400) % 500}`;
  }
}

const PREFIX = 3;                                   // chosen for this list size
const bucket = new Map();
for (const p of list()) {
  const h = createHash("sha1").update(p).digest("hex").toUpperCase();
  const prefix = h.slice(0, PREFIX);
  if (!bucket.has(prefix)) bucket.set(prefix, []);
  bucket.get(prefix).push(h.slice(PREFIX));
}

createServer((request, response) => {
  const path = new URL(request.url, "http://local").pathname;
  if (path === "/health") return response.writeHead(200).end("ready\n");
  const match = /^\/range\/([0-9A-F]{3})$/.exec(path);
  if (!match) return response.writeHead(404).end("");
  response.writeHead(200, { "content-type": "text/plain" });
  response.end((bucket.get(match[1]) ?? []).join("\n") + "\n");
}).listen(8493, "127.0.0.1");
EOF
node range-server.mjs & server=$!
until curl -sf http://127.0.0.1:8493/health > /dev/null; do :; done

hash() { node -e 'const {createHash}=require("node:crypto");
  process.stdout.write(createHash("sha1").update(process.argv[1]).digest("hex").toUpperCase())' "$1"; }

for password in library sea-shell-7; do
  full=$(hash "$password"); prefix=${full:0:3}; tail=${full:3}
  curl -s "http://127.0.0.1:8493/range/$prefix" -o bucket.txt
  echo "password    : $password"
  echo "  prefix sent: $prefix   (first 3 characters of the full hash)"
  echo "  bucket size: $(wc -l < bucket.txt | tr -d ' ') tails"
  if grep -qx "$tail" bucket.txt; then echo "  result     : IN the list, rejected"
  else echo "  result     : not in the list, accepted"; fi
done

kill $server
rm -f bucket.txt range-server.mjs
```

```text
password    : library
  prefix sent: 002   (first 3 characters of the full hash)
  bucket size: 52 tails
  result     : IN the list, rejected
password    : sea-shell-7
  prefix sent: 252   (first 3 characters of the full hash)
  bucket size: 41 tails
  result     : not in the list, accepted
```

The client is the party that decides; the server only returns a bucket and never learns
which record was queried. Prefix values and bucket sizes depend on the list and the hash
function; the flow itself does not change. The same procedure works just as well with a
list kept on your own infrastructure — you already hold the list there, and the prefix
layer still keeps the checking service from ever seeing the password.

Where the check runs is a separate decision. It is mandatory on registration and
password-change flows, and it can also run at login: once the user signs in correctly,
that password is available and can be checked; if it turns up on the list, the user is
prompted to change it — at the same point as the parameter upgrade from the previous
lessons.

#### The Policy, Written Out

The measurements so far converge on a single policy.

**Set a minimum length and keep the maximum generous.** An eight-character floor is low;
twelve is more defensible. A ceiling exists not to constrain the user but to cut off
denial of service built from very long input: a memory-hard derivation function costs more
as the input grows, so "the hash produces fixed-length output anyway" does not make the
ceiling unnecessary. The common advice is to accept at least 64 characters and put the
limit somewhere in the hundreds. A field that cuts off at forty characters, on the other
hand, forces a password-manager user to type it by hand.

**Do not require character classes.** Their measured effect is pushing the user into a
predictable pattern. Length and the breach-list check take the rule's place.

**Accept every character.** Spaces, punctuation, and non-ASCII letters included. Every
character set that gets restricted narrows the user's space of choice. The
normalization step established in the previous lesson is what makes this acceptance
safe.

**Do not block pasting.** A field that blocks pasting makes using a password manager
impossible and pushes the user toward a memorable — that is, weak — password.

**Give feedback while typing.** The user should see whether the password will be
accepted as they type it; a form that rejects it after submission pushes the user toward
satisfying the rule with the least effort.

**Make the list check mandatory.** Checking against a breached password list is more
effective than every character rule combined, and the method above does it without
exposing the password.

#### Summary

- A policy's value is measured by the pattern space users produce in response to the
  rule, not by the theoretical search space.
- Requiring character classes narrows the pattern space; raising the minimum length
  produces a wider space from the same user behavior.
- Calendar-driven periodic change adds four and a half bits over twenty periods; its
  defensible form is conditional change tied to a sign of a leak.
- Checking against a breached password list is more effective than steering the user's
  choice with rules.
- Querying by hash prefix checks without exposing the password or the full hash; prefix
  length is chosen to hold the bucket size at the target `k`.

#### Next Step

However well a password is chosen, it gets forgotten, and every system has to offer a
way back in. That path is a second door bypassing authentication entirely: built wrong,
it lets someone who has never known the password into the account. The next lesson
covers the reset flow's attack surface — a token that is single-use, short-lived, and
stored as a hash, returning the same response against user enumeration, and what a
reset does to sessions.

### Account Recovery

Source: https://academia.sh/en/courses/authentication-and-authorization/account-recovery

The password reset flow's attack surface: the token being randomly generated, stored as a hash, single-use, and short-lived, returning the same response against user enumeration, and what a reset does to sessions.

The previous lesson established how a password gets chosen. This one takes on the case
where it gets forgotten — a second door bypassing the system's most carefully built
authentication flow entirely. When a library member cannot recall their password, the
system has to recognize them without one. How that happens is a boundary built on top of
every defense from the previous three lessons: if recovery is weak, it does not matter
how well the password was stored.

The flow has three steps: the user reports an address, the system sends a value to that
address that only someone who can reach it will receive, and whoever brings it back sets
a new password. The whole lesson is about the properties of that value — the **reset
token** — and what the first step's response gives away.

#### The Token's Four Properties

A reset token is a short-lived credential, and it demands the same care as a password.

**Randomly generated.** A token derived from a member number, a timestamp, or a
sequential counter is predictable; the source is a cryptographic random number
generator, at least thirty-two bytes long.

**Stored as a hash.** What sits in the database is not the token but its hash — the same
reasoning as a password hash: anyone who can read the database should not be able to use
a pending reset request. The token's high entropy means a slow hash is not needed; a
single-pass hash is enough.

**Single-use.** It closes on first use. A token that does not close is enough for
someone who later gains access to the mailbox to seize the account all over again.

**Short-lived.** A fifteen-minute window is standard; the measure is how long it takes
the user to open their email and click the link. A long lifetime turns the token into a
standing key waiting in the mailbox.

The script below sets up all four properties together and proves the last two with
queries.

```bash
cat > recovery.mjs <<'EOF'
import { randomBytes, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE reset(
  hash TEXT PRIMARY KEY, member TEXT NOT NULL,
  issued INTEGER NOT NULL, expires INTEGER NOT NULL, used INTEGER)`);

const hash = (b) => createHash("sha256").update(b).digest("hex");
const now = () => Date.now();
const LIFETIME_MS = 15 * 60 * 1000;                    // 15 minutes

function issue(member, lifetime = LIFETIME_MS) {
  const token = randomBytes(32).toString("base64url");
  db.prepare("INSERT INTO reset VALUES (?,?,?,?,NULL)")
    .run(hash(token), member, now(), now() + lifetime);
  return token;                                  // goes out only in the link
}

function redeem(token) {
  const record = db.prepare("SELECT * FROM reset WHERE hash = ?").get(hash(token));
  if (!record) return "invalid";
  if (record.used !== null) return "already used";
  if (now() > record.expires) return "expired";
  db.prepare("UPDATE reset SET used = ? WHERE hash = ? AND used IS NULL")
    .run(now(), record.hash);
  return `accepted: ${record.member}`;
}

const valid = issue("U-1001");
const stale = issue("U-1005", -1000);              // expired the instant it was issued

console.log("issued token (goes out in the link):", valid.slice(0, 16) + "...");
console.log("stored in the database  :",
  db.prepare("SELECT hash FROM reset WHERE member='U-1001'").get().hash.slice(0, 16) + "...");
console.log("is the token in the table:",
  db.prepare("SELECT COUNT(*) c FROM reset WHERE hash = ?").get(valid).c > 0);

console.log("\nfirst use             :", redeem(valid));
console.log("second use             :", redeem(valid));
console.log("expired token          :", redeem(stale));
console.log("unknown token          :", redeem(randomBytes(32).toString("base64url")));

console.log("\nrecord state:");
for (const r of db.prepare(
  `SELECT member, substr(hash,1,10) h, (expires - issued)/1000 lifetime_s,
          CASE WHEN used IS NULL THEN 'open' ELSE 'closed' END state
   FROM reset ORDER BY member`).all()) {
  console.log(`  ${r.member}  ${r.h}  lifetime=${r.lifetime_s} s  state=${r.state}`);
}
EOF
node recovery.mjs
```

```text
issued token (goes out in the link): azp2j5foOE_Ya9fl...
stored in the database  : d4fa354bedb75dd4...
is the token in the table: false

first use             : accepted: U-1001
second use             : already used
expired token          : expired
unknown token          : invalid

record state:
  U-1001  d4fa354bed  lifetime=900 s  state=closed
  U-1005  a78c706926  lifetime=-1 s  state=open
```

Token values change on every run; the meaning of the lines does not. The third line
confirms the storage decision: querying for the token itself finds no record, since the
table holds its hash, not the token.

The next four lines show the token's status chart: first use accepted, second rejected,
expired rejected, never-issued rejected. Each outcome is a separate code path needing
its own test; the second is the one most often skipped.

The `AND used IS NULL` condition is not a detail. If two concurrent requests bring in
the same token, an unconditional update accepts both; a conditional one lets only one
change the row — the Advanced SQL course's conditional-update pattern, showing up here.

#### User Enumeration

The flow's first step is the user reporting an address. The server's response should not
say whether that address is registered. If it does, the flow becomes a membership-lookup
tool: someone trying a list of addresses learns which ones belong to library members.
This is called **user enumeration**.

What leaks is not a password, but it is not trivial either — for a library, membership
is personal information, and in other domains the same leak has far graver
consequences.

The block below runs two setups side by side. The first is the setup that should be
rejected, included only for comparison.

```bash
cat > recovery-server.mjs <<'EOF'
import { createServer } from "node:http";
import { randomBytes, createHash, scryptSync } from "node:crypto";

const MEMBERS = new Map([["ben@example.invalid", "U-1001"], ["derek@example.invalid", "U-1005"]]);
const BASE_MS = 120;                       // baseline time spent on both branches

const body = (request) => new Promise((resolve) => {
  let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(JSON.parse(v || "{}")));
});

function issueToken(member) {                // the real cost: generation + hash
  const t = randomBytes(32).toString("base64url");
  scryptSync(t, member, 32, { N: 2 ** 12, r: 8, p: 1 });
  return createHash("sha256").update(t).digest("hex");
}

createServer(async (request, response) => {
  const path = new URL(request.url, "http://local").pathname;
  if (path === "/health") return response.writeHead(200).end("ready\n");

  const { email } = await body(request);
  const member = MEMBERS.get(email);

  if (path === "/flawed/reset") {        // the setup that should be rejected
    if (!member) return response.writeHead(404, { "content-type": "application/json" })
      .end(JSON.stringify({ error: "unregistered_address" }) + "\n");
    issueToken(member);
    return response.writeHead(200, { "content-type": "application/json" })
      .end(JSON.stringify({ status: "sent", member }) + "\n");
  }

  const start = Date.now();
  if (member) issueToken(member); else issueToken("none");   // same work on both branches
  const remaining = BASE_MS - (Date.now() - start);
  setTimeout(() => {
    response.writeHead(202, { "content-type": "application/json" })
      .end(JSON.stringify({ status: "request received" }) + "\n");
  }, remaining > 0 ? remaining : 0);
}).listen(8494, "127.0.0.1");
EOF
node recovery-server.mjs & server=$!
until curl -sf http://127.0.0.1:8494/health > /dev/null; do :; done

attempt() {
  curl -s -X POST "http://127.0.0.1:8494$1" -H 'content-type: application/json' \
    -d "{\"email\":\"$2\"}" -w '  HTTP %{http_code}  time=%{time_total} s\n'
}

echo "--- the setup that should be rejected ---"
echo "registered address:";   attempt /flawed/reset ben@example.invalid
echo "unregistered address:";  attempt /flawed/reset nobody@example.invalid

echo "--- correct setup ---"
echo "registered address:";   attempt /reset ben@example.invalid
echo "unregistered address:";  attempt /reset nobody@example.invalid

kill $server
rm -f recovery-server.mjs
```

```text
--- the setup that should be rejected ---
registered address:
{"status":"sent","member":"U-1001"}
  HTTP 200  time=0.007614 s
unregistered address:
{"error":"unregistered_address"}
  HTTP 404  time=0.000665 s
--- correct setup ---
registered address:
{"status":"request received"}
  HTTP 202  time=0.122979 s
unregistered address:
{"status":"request received"}
  HTTP 202  time=0.123334 s
```

Timing changes with the machine; the pattern of divergence does not. The rejected
setup leaks through three separate channels.

First, the status code: 200 versus 404 names two outcomes. Second, the body — for a
registered address, even the member number comes back. Third, timing: generating a token
for a registered address leaves a measurable delay, while an unregistered one returns
instantly. Fixing two of three still leaves the leak; all three are handled together.

In the correct setup, all three channels are closed: same code, same body, same timing.
The server does the same work on both branches and settles onto a fixed baseline time.
The code is 202, because it says "request received" — "sent" would give away that it
could be.

The same rule holds at the second step. Invalid, already-used, and expired tokens all
get the user one message: "this link is no longer valid, request a new one." Separate
messages would report the token's exact state and, indirectly, whether the account
exists. The logs keep the distinction — operations still needs to know which path ran.

#### Carrying the Token

The token travels inside a link, and that transport has its own rules.

The address can leak to third parties through the referrer header the moment it is
visited. The reset page should not load outside resources, and its referrer policy
should be strict — the same decision the Client-Side Security topic already made applies
here directly.

The second rule concerns logs: address components get written to the server's access
logs, and a token traveling in the query string lands there too. The redaction rule from
the Server-Side Fundamentals course has to cover reset links as well.

Third, the reset page must not hold onto the token permanently. It is read once when the
page opens, used when the new password is submitted, and cleared from the address bar.

#### Side Effects of a Reset

A successful reset does more than change the password.

**Open sessions are terminated.** A password reset means the account may have been
compromised; closing every session tied to that account is an inseparable part of the
reset. How that is done is the next lesson's subject.

**Other open reset tokens are closed.** More than one request may have been made for the
same account; the unused ones are invalidated in the same operation.

**The user is notified.** The registered address gets a separate notice that the
password was changed. This notice is the only mechanism that lets the user notice if
they were not the one who did it.

**No automatic sign-in.** After the reset, the user goes through the normal login flow
with the new password. Having the reset flow open a session directly would put the
recovery door on equal footing with the login door.

Finally, the request step is subject to **rate limiting**. The limit applies per target
address and per source address; otherwise the flow becomes a scalable tool for trying
address lists. The response when the limit is exceeded must not diverge either — the
same 202, the same body.

#### Summary

- A reset token is randomly generated, stored as a hash, single-use, and short-lived;
  each of the four properties is tested in its own code path.
- Storing the token as a hash keeps anyone who can read the database from using pending
  reset requests.
- User enumeration leaks through three channels — status code, body, and timing; the
  leak continues until all three are closed together.
- The token travels in a link; the referrer header, access logs, and address bar are
  this transport's leak points.
- A successful reset closes open sessions and other tokens, notifies the user, and does
  not sign them in automatically.

#### Next Step

This lesson's last rule raises the next one's question: what does "close every session
tied to the account" actually mean? If the session identifier sits in the browser, how
does the server invalidate it, and where does the user see which devices they signed in
from? The next lesson moves to session security: renewing the identifier at login,
listing and individually terminating concurrent sessions, and enforcing idle and
absolute lifetime limits together.

### Session Security

Source: https://academia.sh/en/courses/authentication-and-authorization/session-security

Renewing the session identifier at login, the observable result of session fixation, listing and individually terminating concurrent sessions, enforcing idle and absolute lifetime limits together, and the role of cookie attributes.

The previous lesson's last rule was that a successful reset closes every session tied to
the account. In practice, that means a session is a record kept on the server side: what
can be closed is what has a record. This lesson covers a session's life from opening to
termination.

The Session-Based Authentication lesson established what a session is: the server keeps
a record, hands its identifier to the browser as a cookie, and recognizes the user on
later requests by that identifier. The questions here are when it changes, how long it
lives, and who can close it.

#### Session Fixation and Renewal at Login

A user can hold a session before signing in — a language preference, a cart, search
history sit in an anonymous session. What happens to that session at login is a
decision between two options: the same identifier gets treated as signed in, or it gets
renewed.

The first option is open to a problem called **session fixation**: someone who knows the
user's pre-login identifier keeps holding it the moment the user signs in, and now holds
the identifier of a signed-in session. How it was learned is a separate question; the
flaw is that a learned identifier stays valid after login.

The block below runs two setups side by side on a local server.

```bash
cat > session-server.mjs <<'EOF'
import { createServer } from "node:http";
import { randomBytes } from "node:crypto";

const sessions = new Map();                  // id -> { member }

const readId = (request) =>
  /(?:^|;\s*)session=([^;]+)/.exec(request.headers.cookie ?? "")?.[1];

function newSession(member = null) {
  const id = randomBytes(24).toString("base64url");
  sessions.set(id, { member });
  return id;
}

const cookie = (id) => `session=${id}; Path=/; HttpOnly; SameSite=Lax`;

createServer((request, response) => {
  const path = new URL(request.url, "http://local").pathname;
  const id = readId(request);

  if (path === "/health") return response.writeHead(200).end("ready\n");

  if (path === "/") {                          // anonymous visit: opens a session
    if (id && sessions.has(id)) return response.writeHead(200).end(id + "\n");
    const fresh = newSession();
    return response.writeHead(200, { "set-cookie": cookie(fresh) }).end(fresh + "\n");
  }

  if (path === "/flawed/login") {              // the setup that should be rejected
    const s = sessions.get(id);
    if (!s) return response.writeHead(400).end("no session\n");
    s.member = "U-1001";                          // same id, now signed in
    return response.writeHead(200).end(id + "\n");
  }

  if (path === "/login") {                      // correct setup
    if (id) sessions.delete(id);      // the old id is invalidated
    const fresh = newSession("U-1001");
    return response.writeHead(200, { "set-cookie": cookie(fresh) }).end(fresh + "\n");
  }

  if (path === "/me") {
    const s = sessions.get(id);
    return response.writeHead(s ? 200 : 401).end((s ? (s.member ?? "anonymous") : "invalid") + "\n");
  }

  response.writeHead(404).end("");
}).listen(8495, "127.0.0.1");
EOF
node session-server.mjs & server=$!
until curl -sf http://127.0.0.1:8495/health > /dev/null; do :; done

probe() {
  jar=$(mktemp); route=$1
  before=$(curl -s -c "$jar" -b "$jar" http://127.0.0.1:8495/)
  after=$(curl -s -c "$jar" -b "$jar" "http://127.0.0.1:8495$route")
  echo "  id before login: ${before:0:14}..."
  echo "  id after login : ${after:0:14}..."
  echo "  id changed     : $([ "$before" = "$after" ] && echo no || echo yes)"
  echo "  /me with old id: $(curl -s -H "cookie: session=$before" http://127.0.0.1:8495/me)"
  rm -f "$jar"
}

echo "--- the setup that should be rejected (/flawed/login) ---"; probe /flawed/login
echo "--- correct setup (/login) ---";                            probe /login

kill $server
rm -f session-server.mjs
```

```text
--- the setup that should be rejected (/flawed/login) ---
  id before login: gRGPZYwR_quHEa...
  id after login : gRGPZYwR_quHEa...
  id changed     : no
  /me with old id: U-1001
--- correct setup (/login) ---
  id before login: T6XXF7v8d6luLL...
  id after login : HJTvwh-Ak1s590...
  id changed     : yes
  /me with old id: invalid
```

Identifier values change on every run; the difference between the two setups does not.
In the setup that should be rejected, an identifier captured before login answers as
U-1001 afterward. In the correct setup the same identifier comes back invalid, because
the old record is deleted and a new one opened at the moment of login.

The rule fits in one sentence: **the session identifier is renewed on every operation
that changes the privilege level.** Login is the clearest example; a password change,
completing a second factor, and role elevation fall under it too. Data that needs to
carry over from the anonymous session — a language preference, say — gets copied to the
new record during renewal. What does not carry over is the identifier itself.

#### Concurrent Sessions

A member can be signed in from more than one place at once — the desktop at the branch,
their phone, the hall terminal. Each is a separate session record, each terminable on
its own. The "active sessions" list is the only place the user can see whether their
account has an unexpected access.

The record holds not the identifier itself but its hash — the same reasoning as the
reset token in the previous lesson: anyone who can read the database should not be able
to use an active session.

```bash
cat > session-store.mjs <<'EOF'
import { randomBytes, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE session(
  hash TEXT PRIMARY KEY, member TEXT NOT NULL, device TEXT NOT NULL,
  opened INTEGER NOT NULL, last_seen INTEGER NOT NULL)`);

const hash = (k) => createHash("sha256").update(k).digest("hex");
const IDLE_MS = 30 * 60 * 1000;              // idle limit: 30 minutes
const ABSOLUTE_MS = 12 * 60 * 60 * 1000;     // absolute lifetime: 12 hours
const T0 = Date.parse("2026-03-10T09:00:00Z");   // this example's "now"

function open(member, device, openedAgo, seenAgo) {
  const id = randomBytes(24).toString("base64url");
  db.prepare("INSERT INTO session VALUES (?,?,?,?,?)")
    .run(hash(id), member, device, T0 - openedAgo, T0 - seenAgo);
  return id;
}

const min = 60 * 1000, hr = 60 * min;
open("U-1001", "desktop-branch-1", 2 * hr, 3 * min);
const phone = open("U-1001", "phone", 1 * hr, 45 * min);
const old = open("U-1001", "hall-terminal", 14 * hr, 5 * min);
open("U-1002", "phone", 30 * min, 1 * min);

const state = `CASE
  WHEN ? - last_seen > ${IDLE_MS} THEN 'idle limit reached'
  WHEN ? - opened    > ${ABSOLUTE_MS} THEN 'absolute lifetime reached'
  ELSE 'valid' END`;

const list = (member) => db.prepare(
  `SELECT device, (? - opened)/60000 AS opened_min, (? - last_seen)/60000 AS idle_min,
          ${state} AS state
   FROM session WHERE member = ? ORDER BY opened`).all(T0, T0, T0, T0, member);

const print = (title, member) => {
  console.log(title);
  for (const s of list(member)) {
    console.log(`  ${s.device.padEnd(16)} opened=${String(s.opened_min).padStart(3)} min  ` +
      `idle=${String(s.idle_min).padStart(2)} min  ${s.state}`);
  }
};

print("U-1001 sessions:", "U-1001");

const removed = db.prepare("DELETE FROM session WHERE hash = ? AND member = ?")
  .run(hash(old), "U-1001").changes;
console.log(`\none session terminated (rows affected: ${removed})`);
print("U-1001 sessions:", "U-1001");

const all = db.prepare("DELETE FROM session WHERE member = ?").run("U-1001").changes;
console.log(`\nall sessions closed after reset (rows affected: ${all})`);
console.log("U-1001 remaining sessions:",
  db.prepare("SELECT COUNT(*) c FROM session WHERE member='U-1001'").get().c);
console.log("U-1002 remaining sessions:",
  db.prepare("SELECT COUNT(*) c FROM session WHERE member='U-1002'").get().c);
console.log("is the phone id still valid:",
  db.prepare("SELECT COUNT(*) c FROM session WHERE hash = ?").get(hash(phone)).c > 0);
EOF
node session-store.mjs
```

```text
U-1001 sessions:
  hall-terminal    opened=840 min  idle= 5 min  absolute lifetime reached
  desktop-branch-1 opened=120 min  idle= 3 min  valid
  phone            opened= 60 min  idle=45 min  idle limit reached

one session terminated (rows affected: 1)
U-1001 sessions:
  desktop-branch-1 opened=120 min  idle= 3 min  valid
  phone            opened= 60 min  idle=45 min  idle limit reached

all sessions closed after reset (rows affected: 2)
U-1001 remaining sessions: 0
U-1002 remaining sessions: 1
is the phone id still valid: false
```

The `AND member = ?` condition in the delete statement previews the entire next topic: a
user should only be able to close their own session. Without it, anyone who knows an
identifier could close any session — this lesson's first glimpse of object-level access
control.

The last three lines prove the reset rule: every one of U-1001's sessions closed,
U-1002's was untouched, and the phone identifier held earlier is no longer in the
record.

#### Two Lifetime Limits

The output's status column evaluates two separate limits together.

The **idle limit** measures time since the last access, keeping a session from staying
open on a device the user is not sitting at. The phone session hit it — unused for
forty-five minutes.

The **absolute lifetime** measures time since the session opened, regardless of use,
keeping a continuously used session from living forever. The hall-terminal session hit
it — open for fourteen hours despite being used five minutes ago.

The two coexist because they solve separate problems: with only an idle limit, a
hijacked session can be refreshed forever with regular requests; with only an absolute
lifetime, a session on an abandoned device stays open until the limit hits.

The values chosen depend on the account's privilege — a short idle limit for a staff
session at the loan desk, a longer one for a member session; the measure is how wide a
door the session opens.

Enforcing the limits happens in a query; a separate cleanup job deletes expired records.
Neither substitutes for the other — a lagging cleanup job still leaves the query
deciding correctly, and a late query still gets its record removed by the cleanup job.

#### Carrying the Identifier

The session identifier travels between browser and server in a cookie, and the cookie's
attributes protect it. The server above sets three of them.

`HttpOnly` keeps the cookie from being read by scripts on the page: when cross-site
scripting, covered in the Client-Side Security topic, happens on a page, this attribute
stops the identifier from being read directly.

`SameSite` limits the cookie from being attached to requests started by other sites, and
it is the first defense against cross-site request forgery.

`Secure` is absent from the example above because the local server speaks plain HTTP. In
a real deployment it is mandatory — the cookie goes out only over secure transport.
Without transport security the other two attributes are worth little; the identifier
travels the network in the open.

These three attributes make it harder, not impossible, for the identifier to fall into
someone else's hands — an event called **session hijacking**. A second layer sits on
the server side for this reason: the session record holds device and source
information, and an unexpected change terminates the session and asks for
authentication again. The strictness of this check is a trade-off; a rule that keeps
kicking out a user who switches networks pushes them toward never closing the session at
all.

#### Summary

- The session identifier is renewed on every operation that changes the privilege
  level; in a setup that skips this, an identifier known before login stays valid after
  it.
- Sessions are kept as records on the server side, with the identifier's hash stored;
  what can be closed is what has a record.
- An account's concurrent sessions can be listed, closed individually, and terminated
  in bulk during a reset.
- The idle limit and the absolute lifetime solve separate problems and are enforced
  together.
- Cookie attributes protect the identifier in transit; the server-side device check is
  a second layer, and its strictness is balanced against user behavior.

#### Next Step

One question ran through this entire topic: how does the server reliably know who it is
talking to? The `AND member = ?` condition in the session-delete query pointed at a
different one — what someone whose identity is known is allowed to do. The next topic
takes up this second question, starting with role-based access control: binding
permissions to a role rather than a person, how role inheritance hides the effective
permission set, and how library staff's permissions get derived from a chart.

## Authorization Models

Comparing role-, attribute-, relationship- and policy-based access control models across the same set of scenarios, mapping token scopes onto endpoints, tenant isolation, and object-level access control.

### Role-Based Access Control

Source: https://academia.sh/en/courses/authentication-and-authorization/role-based-access-control

Binding permissions to duty rather than to the person, how role inheritance conceals the effective permission set, where the role-based decision is right and wrong across a common scenario set, and measuring role explosion.

The previous topic established the answers to "who is in front of me." The
`AND member = ?` condition in the session-deletion query opened the second question:
what someone whose identity is known is allowed to do. This topic addresses that
question and answers it with four separate models. The models do not substitute for one
another; each can express a particular kind of constraint, and not another.

For the comparison to be meaningful, the four models will evaluate **the same scenario
set**. The set consists of eight requests drawn from the library loan service, and each
scenario also has a correct answer — the answer the library's business rule gives. Each
model's success is measured by how well its own decisions agree with that answer.

#### Role, Permission, and Inheritance

**Role-based access control** binds permissions to duty rather than to the person. This
model was established for database accounts in the Relational Database Administration
course; the difference here is the layer. There, what was checked was which table a
connection could touch; here, what is checked is which operation a request can carry
out. The two layers exist separately, and neither substitutes for the other: if the
check at the application layer is skipped, the database layer is the last line of
defense.

A **permission** is the combination of an action and a resource type: `view:loan`,
`delete:fine`. A **role** is a named set of permissions. **Role inheritance** is one role
covering another role's permissions; the branch manager inherits the clerk role so that
it can do everything a clerk can.

Inheritance has a cost: a role's actual permission set is not visible where it is
written — it is computed by taking the closure of the inheritance chain. The script
below produces that closure and evaluates the common scenario set.

```bash
cat > rbac.mjs <<'EOF'
const ROLE_PERMISSIONS = {
  member:         ["view:loan", "view:list"],
  clerk:          ["update:loan", "create:loan"],
  branch_manager: ["delete:fine", "delete:loan"],
};
const ROLE_INHERITANCE = { member: [], clerk: ["member"], branch_manager: ["clerk"] };
const SUBJECT_ROLES = {
  "U-1001": ["member"], "U-1002": ["member"], "U-1005": ["member"],
  "P-2001": ["clerk"], "Y-3001": ["branch_manager"],
};

// Common scenario set: the four models in this topic evaluate the same set.
// [name, subject, action, object, object attributes, time, expected answer]
const SCENARIOS = [
  ["S1", "U-1001", "view",   "loan:501", { owner: "U-1001", branch: "S-02" }, "10:15", true],
  ["S2", "U-1001", "view",   "loan:502", { owner: "U-1002", branch: "S-02" }, "10:16", false],
  ["S3", "P-2001", "view",   "loan:502", { owner: "U-1002", branch: "S-02" }, "10:20", true],
  ["S4", "P-2001", "view",   "loan:503", { owner: "U-1005", branch: "S-01" }, "10:21", false],
  ["S5", "P-2001", "update", "loan:502", { owner: "U-1002", branch: "S-02" }, "22:40", false],
  ["S6", "Y-3001", "delete", "fine:77",  { owner: "U-1002", branch: "S-02" }, "11:05", true],
  ["S7", "U-1002", "view",   "list:9",   { owner: "U-1001", branch: "S-02" }, "12:00", true],
  ["S8", "U-1005", "view",   "list:9",   { owner: "U-1001", branch: "S-02" }, "12:01", false],
];

function effectivePermissions(role, seen = new Set()) {
  if (seen.has(role)) return new Set();            // guards against an inheritance cycle
  seen.add(role);
  const set = new Set(ROLE_PERMISSIONS[role] ?? []);
  for (const parent of ROLE_INHERITANCE[role] ?? []) {
    for (const permission of effectivePermissions(parent, seen)) set.add(permission);
  }
  return set;
}

const subjectPermissions = (subject) => {
  const k = new Set();
  for (const role of SUBJECT_ROLES[subject] ?? []) for (const p of effectivePermissions(role)) k.add(p);
  return k;
};

const rbac = (subject, action, object) =>
  subjectPermissions(subject).has(`${action}:${object.split(":")[0]}`);

console.log("role            | effective permission set");
console.log("----------------|-----------------------------------------------------");
for (const role of Object.keys(ROLE_PERMISSIONS)) {
  console.log(role.padEnd(15) + " | " + [...effectivePermissions(role)].sort().join(", "));
}

console.log("\nscenario | subject | action    | object     | RBAC   | expect | match");
console.log("---------|---------|-----------|------------|--------|--------|------");
let matched = 0;
for (const [name, subject, action, object, , , expected] of SCENARIOS) {
  const decision = rbac(subject, action, object);
  if (decision === expected) matched++;
  console.log(
    `${name}       | ${subject} | ${action.padEnd(9)} | ${object.padEnd(10)} | ` +
    `${(decision ? "allow" : "deny").padEnd(6)} | ${(expected ? "allow" : "deny").padEnd(6)} | ` +
    (decision === expected ? "+" : "-")
  );
}
console.log(`\nRBAC correct decisions: ${matched}/${SCENARIOS.length}`);
EOF
node rbac.mjs
```

```text
role            | effective permission set
----------------|-----------------------------------------------------
member          | view:list, view:loan
clerk           | create:loan, update:loan, view:list, view:loan
branch_manager  | create:loan, delete:fine, delete:loan, update:loan, view:list, view:loan

scenario | subject | action    | object     | RBAC   | expect | match
---------|---------|-----------|------------|--------|--------|------
S1       | U-1001 | view      | loan:501   | allow  | allow  | +
S2       | U-1001 | view      | loan:502   | allow  | deny   | -
S3       | P-2001 | view      | loan:502   | allow  | allow  | +
S4       | P-2001 | view      | loan:503   | allow  | deny   | -
S5       | P-2001 | update    | loan:502   | allow  | deny   | -
S6       | Y-3001 | delete    | fine:77    | allow  | allow  | +
S7       | U-1002 | view      | list:9     | allow  | allow  | +
S8       | U-1005 | view      | list:9     | allow  | deny   | -

RBAC correct decisions: 4/8
```

#### Where the Model Is Right and Where It Is Wrong

Four of the eight scenarios come out right and four come out wrong. All the wrong ones
lean the same direction: the model grants more permission than it should. This direction
is not an accident — it comes from the model's structure.

S2 is the clearest case. U-1001 can view its own loan record, but not another member's
record. The role-based model cannot tell these two apart, because its decision is based
only on the subject and the resource *type*. `loan:501` and `loan:502` are the same
type; which one belongs to whom is information the model cannot see.

S4 is the staff side of the same problem. A clerk can view loan records, but which
branch's records it may view is not written into the permission name.

S5 is about time. Loan records not being changed outside business hours is a business
rule, and the role-based model has no place to express it — permissions do not see the
request's context.

S8 should be read together with S7. In both, the model says "allow"; in S7 this is
correct, in S8 it is wrong. Even the correct one is correct for the wrong reason: the
model does not know that the list is shared with U-1002 and not with U-1005 — it only
knows that every member can view a list. Getting the correct decision for the correct
reason is the subject of the next two lessons.

This foursome has a shared name: the role-based model is a **coarse-grained** model. It
reaches down to the resource's type, but not to its specific instance.

#### Role Explosion

There is one way to resolve missing distinctions within the role-based model itself:
write the missing condition into the role's name. For the branch distinction,
`clerk_S01`, `clerk_S02`; for the hours distinction, on-hours and off-hours versions of
each. The cost of this path can be computed.

```bash
cat > role-explosion.mjs <<'EOF'
const dimensions = [
  ["duty", 4, "reader, clerk, cataloger, branch manager"],
  ["branch", 12, "the number of the library's branches"],
  ["hours", 2, "on-hours / off-hours"],
  ["record ownership", 2, "own record / someone else's record"],
];

console.log("added condition     | factor | roles required    | example");
console.log("--------------------|--------|--------------------|--------------------------------");
let roles = 1;
for (const [name, factor, example] of dimensions) {
  roles *= factor;
  console.log(name.padEnd(19) + " | " + String(factor).padStart(6) + " | " +
    String(roles).padStart(18) + " | " + example);
}
console.log(`\nexample role name: clerk_S02_on_hours_others_record`);
console.log(`assignments: the correct one of ${roles} roles is chosen for each staff member`);
EOF
node role-explosion.mjs
```

```text
added condition     | factor | roles required    | example
--------------------|--------|--------------------|--------------------------------
duty                |      4 |                  4 | reader, clerk, cataloger, branch manager
branch              |     12 |                 48 | the number of the library's branches
hours               |      2 |                 96 | on-hours / off-hours
record ownership    |      2 |                192 | own record / someone else's record

example role name: clerk_S02_on_hours_others_record
assignments: the correct one of 192 roles is chosen for each staff member
```

Four duties climb to forty-eight roles once a branch is added; two more conditions push
it to a hundred ninety-two. What matters is not the number itself but the shape of the
growth: every new condition **multiplies** the role count. When a thirteenth branch
opens, sixteen new roles must be defined, and each one is a record someone looks at by
hand.

The second cost is readability. What a role named `clerk_S02_on_hours_others_record`
means can be worked out from its name, but noticing one mistake among a hundred and
ninety-two such names cannot. A staff member assigned to the wrong role becomes
invisible to a glance at the permission chart.

The limit here is not a flaw in the role-based model but its scope. The model stays
plain and auditable exactly when the duty is fixed and the decision does not depend on
the request's context.

#### Where the Model Is Used Correctly

The role-based model does not need to be abandoned; its limit needs to be known. A sound
setup makes the following distinction.

**The coarse-grained decision is given by role.** The answer to "is this request type
open to this duty?" lives in the role. A non-staff person never reaching the loan-record
update endpoint at all is the job of the role check, and it happens at the very start of
the request.

**The fine-grained decision is given by a separate layer.** The question "is this
specific record open to this person?" is the subject of the following lessons, and it
concerns the record itself, not the role.

**Roles are derived from duty, not from the person.** Adding "let this person also have
access" to a role contradicts the role's name. If there is a genuine need, a new role is
defined.

**Role assignments are audited regularly.** The gap between a person's effective
permission set and what their duty requires is measured. The database-side counterpart
of this audit was established in the Relational Database Administration course; the same
procedure is applied on the application side.

#### Summary

- Role-based access control binds permissions to duty; a permission is the combination
  of an action and a resource type.
- Role inheritance makes the effective permission set invisible where it is written; the
  set is computed by taking the closure of the inheritance chain.
- The model is coarse-grained: it reaches the resource's type, not its instance. Across
  the common scenario set, four of eight decisions came out wrong, and all four leaned
  toward too much permission.
- Writing missing conditions into a role's name multiplies the role count with every
  condition; twelve branches and two conditions produce a hundred ninety-two roles.
- The role-based decision remains a coarse-grained filter at the start of the request;
  the record-level decision is given in a separate layer.

#### Next Step

Three of the four wrong decisions come from the same gap: the model cannot see the
request's context. Who owns the record, which branch a staff member works in, what time
the request arrived — all of this is known at decision time but has no place in a
permission's name. The next lesson takes up attribute-based access control, which turns
this information into the decision's input, and re-evaluates the same eight scenarios
with this model.

### Attribute-Based Access Control

Source: https://academia.sh/en/courses/authentication-and-authorization/attribute-based-access-control

Deciding from subject, object, action, and context attributes, deny priority's role in combining rules, the attribute-based model's correct and wrong decisions on the same scenario set, and the consequence of where an attribute is read from.

In the previous lesson's eight scenarios, four decisions came out wrong, and three of
them shared the same cause: the role-based model cannot see the request's context. Who
owns the record, which branch the staff member works in, what time the request
arrived — all of this is information known at decision time, but the permission's name
has no place for it.

**Attribute-based access control** turns this information into the decision's input.
What produces the decision is no longer membership in a permission list, but a rule that
operates on attribute values.

#### Four Sets of Attributes

The model gathers attributes from four sources at decision time.

**Subject attributes** belong to the person making the request: their code, role,
the branch they work at, seniority, which factors authenticated them.

**Object attributes** belong to the record being accessed: its type, owner, the branch
it belongs to, its confidentiality class, its creation date.

**Action** is the operation to be carried out; it carries the same meaning as in the
role-based model.

**Context attributes** belong neither to the subject nor to the object; they belong to
the request itself: the time, the network location, the device the request arrived
from, the session's age.

A rule is a condition over the values of these four sets. The rule "can view its own
record" is the equality of the subject's code with the object's owner; the branch rule
is the equality of two branch attributes. These equalities were the thing the
role-based model could not express, because it never looked at the record at all.

#### Combining Rules

More than one rule can match the same request, and their outcomes can conflict. The
combination rule says what happens in that case. The default choice is **deny
priority**: if one of the matching rules says deny, the decision is deny, no matter how
many rules say allow. If no rule matches, the decision is still deny — allow is
something that must be granted explicitly.

The evaluator below applies four rules with this combination and re-decides the
previous lesson's eight scenarios. The last column shows which rule produced the
decision.

```bash
cat > abac.mjs <<'EOF'
const SUBJECTS = {
  "U-1001": { role: "member", branch: "S-02" }, "U-1002": { role: "member", branch: "S-02" },
  "U-1005": { role: "member", branch: "S-01" }, "P-2001": { role: "clerk", branch: "S-02" },
  "Y-3001": { role: "branch_manager", branch: "S-02" },
};

// Common scenario set from the previous lesson.
const SCENARIOS = [
  ["S1", "U-1001", "view",   "loan:501", { owner: "U-1001", branch: "S-02" }, "10:15", true],
  ["S2", "U-1001", "view",   "loan:502", { owner: "U-1002", branch: "S-02" }, "10:16", false],
  ["S3", "P-2001", "view",   "loan:502", { owner: "U-1002", branch: "S-02" }, "10:20", true],
  ["S4", "P-2001", "view",   "loan:503", { owner: "U-1005", branch: "S-01" }, "10:21", false],
  ["S5", "P-2001", "update", "loan:502", { owner: "U-1002", branch: "S-02" }, "22:40", false],
  ["S6", "Y-3001", "delete", "fine:77",  { owner: "U-1002", branch: "S-02" }, "11:05", true],
  ["S7", "U-1002", "view",   "list:9",   { owner: "U-1001", branch: "S-02" }, "12:00", true],
  ["S8", "U-1005", "view",   "list:9",   { owner: "U-1001", branch: "S-02" }, "12:01", false],
];

const duringWorkingHours = (time) => time >= "08:30" && time <= "18:00";
const WRITE_ACTIONS = new Set(["update", "create", "delete"]);

const RULES = [
  { name: "R1 own record", effect: "allow",
    condition: (o, e, n) => e === "view" && n.owner === o.code },
  { name: "R2 branch staff", effect: "allow",
    condition: (o, e, n) => ["clerk", "branch_manager"].includes(o.role) &&
      n.branch === o.branch && ["view", "update", "create"].includes(e) },
  { name: "R3 fine deletion", effect: "allow",
    condition: (o, e, n) => o.role === "branch_manager" && n.branch === o.branch &&
      e === "delete" && n.type === "fine" },
  { name: "R4 off-hours write", effect: "deny",
    condition: (o, e, n, b) => WRITE_ACTIONS.has(e) && !duringWorkingHours(b.time) && o.role !== "branch_manager" },
];

function abac(subjectCode, action, objectName, objectAttrs, time) {
  const o = { code: subjectCode, ...SUBJECTS[subjectCode] };
  const n = { type: objectName.split(":")[0], ...objectAttrs };
  const b = { time };
  const matched = RULES.filter((r) => r.condition(o, action, n, b));
  const deny = matched.find((r) => r.effect === "deny");
  const allow = matched.find((r) => r.effect === "allow");
  if (deny) return [false, `${deny.name} (deny wins)`];
  if (allow) return [true, allow.name];
  return [false, "no matching rule"];
}

console.log("scenario | ABAC  | expect | match | rule that decided");
console.log("---------|-------|--------|-------|----------------------------");
let matched = 0;
for (const [name, subject, action, object, attrs, time, expected] of SCENARIOS) {
  const [decision, reason] = abac(subject, action, object, attrs, time);
  if (decision === expected) matched++;
  console.log(`${name}       | ${(decision ? "allow" : "deny").padEnd(5)} | ` +
    `${(expected ? "allow" : "deny").padEnd(6)} | ${(decision === expected ? "+" : "-").padEnd(5)} | ${reason}`);
}
console.log(`\nABAC correct decisions: ${matched}/${SCENARIOS.length}`);
EOF
node abac.mjs
```

```text
scenario | ABAC  | expect | match | rule that decided
---------|-------|--------|-------|----------------------------
S1       | allow | allow  | +     | R1 own record
S2       | deny  | deny   | +     | no matching rule
S3       | allow | allow  | +     | R2 branch staff
S4       | deny  | deny   | +     | no matching rule
S5       | deny  | deny   | +     | R4 off-hours write (deny wins)
S6       | allow | allow  | +     | R3 fine deletion
S7       | deny  | allow  | -     | no matching rule
S8       | deny  | deny   | +     | no matching rule

ABAC correct decisions: 7/8
```

Three of the four decisions that came out wrong in the role-based model are now
fixed. S2 and S4, by comparing the ownership and branch attributes; S5, by the context
attribute. S8's decision is now also given for the correct reason: the list is not
U-1005's, so no rule matches.

The reason column is the model's second gain. Every decision can be produced together
with the name of the rule that gave it; the answer to "why was it denied" can be written
to a log. In the role-based model, that answer was nothing more than "that permission
was not in the role."

#### Where the Model Falls Short

S7 is still wrong. U-1001 shared reading list number nine with U-1002; U-1002 should be
able to see that list. In the attribute-based model, the way to write this constraint is
to turn the sharing into an attribute: a `sharedWith` field is added to the object, and
the rule checks whether the subject's code is in that list.

This path works at small scale and jams at two points. The first is transitivity: if
U-1002 shares the list with its own working group, the group's members should also gain
access. The attribute list can only express this by including every group member one by
one, and the attribute must be updated every time the group changes. The second is
indirection: a constraint like "the records opened by staff working at the branch a
branch manager manages" rests on a link two steps away and does not fit into a single
attribute field.

Both cases are the signal that the constraint is not an attribute but a
**relationship**. That is the subject of the next lesson.

#### Where the Attribute Comes From, and How Fresh It Is

The real operating question of the attribute-based model is not how the rules are
written but where the attributes are read from. There are two options: the values are
carried inside the identity token, or they are read from the record at decision time.

Reading from the token is fast and requires no extra query. Its cost is that the value
is the value at the moment the token was issued. The example below decides two requests
from a clerk who has changed branches, using both sources.

```bash
cat > freshness.mjs <<'EOF'
const RECORD = { "P-2001": { role: "clerk", branch: "S-01" } };   // moved from S-02 to S-01 at 12:00
const TOKEN = { code: "P-2001", role: "clerk", branch: "S-02", issuedAt: "09:00" };

const rule = (o, n) => o.role === "clerk" && n.branch === o.branch;

const requests = [
  ["loan:502 (branch S-02)", { branch: "S-02" }, false],   // should no longer see it
  ["loan:503 (branch S-01)", { branch: "S-01" }, true],    // should see it now
];

console.log("attribute source          | " + requests.map((i) => i[0]).join(" | "));
console.log("--------------------------|-----------------------|-----------------------");
for (const [name, o] of [["from token (09:00)", { ...TOKEN }],
                         ["from record (live)", { code: "P-2001", ...RECORD["P-2001"] }]]) {
  const cells = requests.map(([, n, expected]) => {
    const decision = rule(o, n);
    return `${decision ? "allow" : "deny"} ${decision === expected ? "(correct)" : "(WRONG)"}`.padEnd(21);
  });
  console.log((name.padEnd(25) + " | " + cells.join(" | ")).trimEnd());
}
EOF
node freshness.mjs
```

```text
attribute source          | loan:502 (branch S-02) | loan:503 (branch S-01)
--------------------------|-----------------------|-----------------------
from token (09:00)        | allow (WRONG)         | deny (WRONG)
from record (live)        | deny (correct)        | allow (correct)
```

The stale value in the token produces a wrong decision in both directions at once: it
allows access to a record the clerk should no longer see, and denies access to the
record it should see. The second one surfaces as a support ticket and gets fixed; the
first one never surfaces at all.

The decision rule reads: **an attribute that changes the decision, and that can change,
is read at decision time.** Only attributes that never change, or whose change within
the token's lifetime is acceptable, are carried in the token. The shorter the token's
lifetime, the less this distinction matters — the lifetime decision in the refresh-token
lesson is the same trade-off wearing a different face here.

Reading from the record costs one query per request. This cost is bounded by reading the
attributes once per request and caching them for the request's duration; the cache's
lifetime must not exceed the request's lifetime.

#### The Auditability of Rules

The expressive power the attribute-based model gains comes with a cost: seeing what the
rules do together gets harder. In the role-based model, an account's effective
permission set could be computed; in attribute-based rules there is no such set, because
the decision depends on the request itself.

Two audit procedures are used instead.

**The scenario set.** This lesson's table is itself an audit tool: known requests and
expected answers are kept as a set, and the set is re-run every time a rule changes. A
minus appearing in the match column means the rule's text needs to be examined.

**Rule coverage.** A rule that never matches in any scenario is either unnecessary or
written incorrectly; a rule that matches in every scenario is most likely too broad. How
many times each rule matches can be counted, and this count says something about how the
rule is written.

A third rule is one of writing discipline: rules must be free of side effects and must
produce the same decision for the same input every time. A rule that writes a record,
calls an external service, or uses a random value during the decision cannot be re-run
and cannot be audited.

#### Summary

- Attribute-based access control produces the decision from subject, object, action, and
  context attributes; the ownership and branch constraints that could not be expressed
  in the role-based model are written here as equality conditions.
- Deny takes priority when combining rules, and the decision is deny if no rule matches.
- The model can produce the reason behind every decision; this makes it possible to log
  the reason for a denial.
- Transitive and indirect links do not fit into an attribute field; these constraints
  are relationships, not attributes.
- An attribute that changes the decision is read at decision time if it can change; a
  stale value carried in the token produces a wrong decision in both directions at once.

#### Next Step

S7 has now been decided wrong two lessons in a row. A shared list's accessibility
belongs neither to the subject nor to the object — it is the link between the two. The
next lesson takes up relationship-based access control, which turns these links into
first-class data: objects and subjects form a graph, and the access question turns into
a reachability query over that graph.

### Relationship-Based Access Control

Source: https://academia.sh/en/courses/authentication-and-authorization/relationship-based-access-control

Turning the links between subjects and objects into first-class data, the access question becoming a reachability query over a graph, transitive access, and the cost difference between checking a single object and listing accessible ones.

In the previous two lessons, S7 was decided wrong. U-1001 had shared reading list number
nine with U-1002; U-1002 should have been able to see that list. Neither the role-based
model nor the attribute-based model could express this, because sharing is a property
of neither the subject nor the object — it is the link between the two.

**Relationship-based access control** turns these links into first-class data. The
subjects and objects in the system become the nodes of a graph, and the links between
them become named edges. The access question turns into a reachability query over this
graph: can the subject reach the object by following a specific sequence of edges?

#### The Graph, Edges, and Path Patterns

The library loan service's relationships can be written with a small number of edge
types. `owner` links a member to their own record. `belongsTo` links a record to its
branch. `worksAt` and `manages` link staff to their branch. `sharedWith` links a list to
the person or group it is shared with. `memberOf` links a person to a group.

An access rule is a **path pattern** made of these edges. The rule "can view its own
loan record" is a one-step pattern: `owner`. The rule "branch staff can view their
branch's records" is a two-step pattern, and the second step follows the edge in
reverse: from staff to branch via `worksAt`, from branch to record via the reverse of
`belongsTo`.

The evaluator below runs these patterns. At each step it moves from one set of nodes to
the neighboring set; this is the edge-type-filtered form of the breadth-first search
from the Data Structures course.

```bash
cat > rebac.mjs <<'EOF'
const EDGES = [                         // [source, relation, target]
  ["U-1001", "owner", "loan:501"], ["U-1002", "owner", "loan:502"],
  ["U-1005", "owner", "loan:503"], ["U-1001", "owner", "list:9"],
  ["loan:501", "belongsTo", "S-02"], ["loan:502", "belongsTo", "S-02"],
  ["loan:503", "belongsTo", "S-01"], ["fine:77", "belongsTo", "S-02"],
  ["P-2001", "worksAt", "S-02"], ["Y-3001", "manages", "S-02"],
  ["list:9", "sharedWith", "U-1002"], ["list:9", "sharedWith", "group:reading-club"],
  ["U-1006", "memberOf", "group:reading-club"],
];

// A "~relation" in a pattern means the edge is followed in reverse.
const PATTERNS = {
  "view:loan":   [["owner"], ["worksAt", "~belongsTo"], ["manages", "~belongsTo"]],
  "update:loan": [["worksAt", "~belongsTo"], ["manages", "~belongsTo"]],
  "create:loan": [["worksAt", "~belongsTo"], ["manages", "~belongsTo"]],
  "delete:loan": [["manages", "~belongsTo"]],
  "delete:fine": [["manages", "~belongsTo"]],
  "view:list":   [["owner"], ["~sharedWith"], ["memberOf", "~sharedWith"]],
};

function step(nodes, relation) {
  const reversed = relation.startsWith("~");
  const name = reversed ? relation.slice(1) : relation;
  const result = new Set();
  for (const [s, r, t] of EDGES) {
    if (r !== name) continue;
    if (!reversed && nodes.has(s)) result.add(t);
    if (reversed && nodes.has(t)) result.add(s);
  }
  return result;
}

function rebac(subject, action, object) {
  for (const pattern of PATTERNS[`${action}:${object.split(":")[0]}`] ?? []) {
    let frontier = new Set([subject]);
    for (const relation of pattern) frontier = step(frontier, relation);
    if (frontier.has(object)) return [true, pattern.join(" -> ")];
  }
  return [false, "no reachable path"];
}

const SCENARIOS = [
  ["S1", "U-1001", "view",   "loan:501", true],
  ["S2", "U-1001", "view",   "loan:502", false],
  ["S3", "P-2001", "view",   "loan:502", true],
  ["S4", "P-2001", "view",   "loan:503", false],
  ["S5", "P-2001", "update", "loan:502", false],
  ["S6", "Y-3001", "delete", "fine:77",  true],
  ["S7", "U-1002", "view",   "list:9",   true],
  ["S8", "U-1005", "view",   "list:9",   false],
];

console.log("scenario | ReBAC | expect | match | path found");
console.log("---------|-------|--------|-------|--------------------------");
let matched = 0;
for (const [name, subject, action, object, expected] of SCENARIOS) {
  const [decision, path] = rebac(subject, action, object);
  if (decision === expected) matched++;
  console.log(`${name}       | ${(decision ? "allow" : "deny").padEnd(5)} | ` +
    `${(expected ? "allow" : "deny").padEnd(6)} | ${(decision === expected ? "+" : "-").padEnd(5)} | ${path}`);
}
console.log(`\nReBAC correct decisions: ${matched}/${SCENARIOS.length}`);

console.log("\ntransitive access (outside the common set):");
for (const code of ["U-1006", "U-1005"]) {
  const [decision, path] = rebac(code, "view", "list:9");
  console.log(`  ${code} -> list:9 : ${decision ? "allow" : "deny"}  (${path})`);
}
EOF
node rebac.mjs
```

```text
scenario | ReBAC | expect | match | path found
---------|-------|--------|-------|--------------------------
S1       | allow | allow  | +     | owner
S2       | deny  | deny   | +     | no reachable path
S3       | allow | allow  | +     | worksAt -> ~belongsTo
S4       | deny  | deny   | +     | no reachable path
S5       | allow | deny   | -     | worksAt -> ~belongsTo
S6       | allow | allow  | +     | manages -> ~belongsTo
S7       | allow | allow  | +     | ~sharedWith
S8       | deny  | deny   | +     | no reachable path

ReBAC correct decisions: 7/8

transitive access (outside the common set):
  U-1006 -> list:9 : allow  (memberOf -> ~sharedWith)
  U-1005 -> list:9 : deny  (no reachable path)
```

S7 is solved for the correct reason for the first time in three lessons: a `sharedWith`
edge runs to the list from U-1002. S8 is denied by the same pattern, because no edge
runs to U-1005.

The last two lines show the model's real strength. U-1006 is not directly linked to the
list; it is a member of the reading club group, and the list is shared with that group.
The two-step path establishes this link. In the attribute-based model, reaching the same
result would have required copying every member of the group into the object's attribute
field and updating that copy every time the group changes. In the graph, group
membership is a single edge, and adding the edge affects every list at once.

#### Where the Model Falls Short

S5 is wrong in this model. Loan records not being updated outside business hours is a
context constraint, and the graph has no counterpart for it — time is not an edge
between two nodes.

This is the mirror image of the previous lesson. The attribute-based model could see
context but not relationships; the relationship-based model can see relationships but
not context. The two models' gaps do not overlap — they complement each other. This
observation is the starting point for the next lesson.

#### Two Separate Questions, and Their Costs

Two different questions are asked in the relationship-based model, and their costs are
not the same.

The first is a **check**: "can this subject reach this object?" The moment an answer is
found, the search stops. The second is a **listing**: "which objects can this subject
reach?" This question is needed to render a list page, and it requires running every
pattern to completion.

```bash
cat > rebac-cost.mjs <<'EOF'
const EDGES = [
  ["U-1001", "owner", "loan:501"], ["U-1002", "owner", "loan:502"],
  ["U-1005", "owner", "loan:503"], ["U-1001", "owner", "list:9"],
  ["loan:501", "belongsTo", "S-02"], ["loan:502", "belongsTo", "S-02"],
  ["loan:503", "belongsTo", "S-01"], ["fine:77", "belongsTo", "S-02"],
  ["P-2001", "worksAt", "S-02"], ["Y-3001", "manages", "S-02"],
  ["list:9", "sharedWith", "U-1002"], ["list:9", "sharedWith", "group:reading-club"],
  ["U-1006", "memberOf", "group:reading-club"],
];
const PATTERNS = {
  "view:loan": [["owner"], ["worksAt", "~belongsTo"], ["manages", "~belongsTo"]],
  "view:list": [["owner"], ["~sharedWith"], ["memberOf", "~sharedWith"]],
};

let edgeReads = 0;
function step(nodes, relation) {
  const reversed = relation.startsWith("~"), name = reversed ? relation.slice(1) : relation;
  const result = new Set();
  for (const [s, r, t] of EDGES) {
    edgeReads++;
    if (r !== name) continue;
    if (!reversed && nodes.has(s)) result.add(t);
    if (reversed && nodes.has(t)) result.add(s);
  }
  return result;
}

const walk = (subject, pattern) => {
  let frontier = new Set([subject]);
  for (const relation of pattern) frontier = step(frontier, relation);
  return frontier;
};

function check(subject, permission, object) {     // stops at the first matching pattern
  for (const pattern of PATTERNS[permission] ?? []) if (walk(subject, pattern).has(object)) return true;
  return false;
}

function accessible(subject, permission) {         // all patterns are walked
  const all = new Set();
  for (const pattern of PATTERNS[permission] ?? []) for (const d of walk(subject, pattern)) all.add(d);
  const type = permission.split(":")[1] + ":";
  return [...all].filter((d) => d.startsWith(type)).sort();
}

console.log("single-object check (stops at the first matching pattern):");
for (const [subject, object] of [["P-2001", "loan:502"], ["U-1001", "loan:501"],
                             ["U-1005", "loan:502"]]) {
  edgeReads = 0;
  const k = check(subject, "view:loan", object);
  console.log(`  ${subject} -> ${object} : ${(k ? "allow" : "deny").padEnd(5)} ` +
    `(${String(edgeReads).padStart(2)} edge reads)`);
}

console.log("\nlist of accessible objects (all patterns are walked):");
for (const [subject, permission] of [["P-2001", "view:loan"], ["U-1001", "view:loan"],
                            ["U-1006", "view:list"], ["U-1005", "view:list"]]) {
  edgeReads = 0;
  const list = accessible(subject, permission);
  console.log(`  ${subject} ${permission.padEnd(16)} -> [${(list.join(", ") || "empty").padEnd(22)}]` +
    ` (${edgeReads} edge reads)`);
}
EOF
node rebac-cost.mjs
```

```text
single-object check (stops at the first matching pattern):
  P-2001 -> loan:502 : allow (39 edge reads)
  U-1001 -> loan:501 : allow (13 edge reads)
  U-1005 -> loan:502 : deny  (65 edge reads)

list of accessible objects (all patterns are walked):
  P-2001 view:loan        -> [loan:501, loan:502    ] (65 edge reads)
  U-1001 view:loan        -> [loan:501              ] (65 edge reads)
  U-1006 view:list        -> [list:9                ] (52 edge reads)
  U-1005 view:list        -> [empty                 ] (52 edge reads)
```

The edge-read counts are specific to this small graph; what matters is the ratio between
them. Two observations stand out.

First, **a deny decision is the most expensive decision**. An allow decision stops at
the first matching pattern; a deny decision requires every pattern to be exhausted.
Denying U-1005 cost sixty-five edge reads; allowing U-1001 cost thirteen. Unauthorized
request traffic loads more cost onto the check layer than authorized traffic does — this
is why rate limiting sits in front of the check layer.

Second, a listing query is not a repetition of the check query. Rendering a list page by
"fetch every record, call the check for each one" produces as many check calls as the
page has entries. The correct structure is to produce the set of accessible objects once
and limit the data query to that set. How this set is passed into the query is a
scale-dependent design decision: small sets travel as a plain list of identifiers, large
sets travel as a join condition added to the resource query.

#### Maintaining the Graph

The relationship-based model's operating cost is maintaining the edges.

**An edge is written together with the source data.** When a loan record is created, its
`owner` edge is created too; if the two are written in separate transactions, requests
arriving in between are decided wrongly. Writing the edge must sit inside the same
transaction that writes the record.

**An edge is deleted together with the source data.** Edges left undeleted quietly leave
access open, and because they produce no error, they go unnoticed. Regularly scanning
for edges whose source no longer exists is the counterpart, here, of the orphan-record
audit from the Relational Database Administration course.

**Path length is bounded.** The longer a pattern is, the wider a set of nodes the search
spreads across. An inheritance chain with unbounded depth — a group's group, and that
group's group — makes the cost unpredictable and makes who has access to what impossible
to follow by eye.

#### Summary

- Relationship-based access control turns links into first-class data; the access
  question turns into a reachability query over a graph.
- An access rule is a path pattern; edges can be followed forward or in reverse.
- Transitive access — a record shared with a group opening up to the group's
  members — is expressed with a single edge and requires no copying into an attribute
  field.
- The model cannot express context constraints; information like the time has no
  counterpart in the graph.
- A deny decision requires every pattern to be exhausted and so costs more than an
  allow decision; a listing query is done by producing the accessible set, not by
  calling the check one object at a time.

#### Next Step

All three models fell short in different places on the same scenario set, and their
gaps complement each other. This points not to a fourth model but to a question about
where these rules are written: will all of them just sit scattered through the
application code in `if` blocks? The next lesson takes up policy-based authorization,
which separates the decision from the code, and compares all four models' decisions in
the same table.

### Policy-Based Authorization

Source: https://academia.sh/en/courses/authentication-and-authorization/policy-based-authorization

Moving the access decision out of application code into an interpreted policy document, the split between the decision point and the enforcement point, comparing the four models' decisions on the same scenario set, and testing a policy in report mode.

Three lessons built three models, and all three fell short in different places on the
same eight scenarios. The role-based model could not see who owned the record; the
attribute-based model could not see the sharing link; the relationship-based model
could not see the time. The gaps do not overlap — they complement each other.

This observation leads not to a fourth model but to a different question: where will
these rules actually live? The three evaluators built so far were each a script; in a
real application, the same rules would be scattered across endpoint handlers, inside
`if` blocks. **Policy-based authorization** moves the rules out of the code into an
interpreted document.

#### Decision Point and Enforcement Point

The split has two sides. The **policy decision point** takes the request and the
relevant data and produces an allow or deny decision; it carries no domain logic, it
only interprets the policy. The **policy enforcement point** is where the decision is
applied: the endpoint handler or the middleware. It does not make the decision — it asks
and abides by the answer.

This split has three concrete payoffs. A rule change stops being a code change; the
policy document can be versioned separately and deployed independently of the code. The
decision's reason is produced in a single place, so it can be logged and tested. The
same policy can be used by more than one service; the rule is not duplicated.

The engine below builds this split. `POLICY` is a data structure and can be kept in a
separate file; the engine interprets it and carries no knowledge specific to the
library.

```bash
cat > policy.mjs <<'EOF'
const EDGES = [
  ["U-1001", "owner", "loan:501"], ["U-1002", "owner", "loan:502"],
  ["U-1005", "owner", "loan:503"], ["U-1001", "owner", "list:9"],
  ["loan:501", "belongsTo", "S-02"], ["loan:502", "belongsTo", "S-02"],
  ["loan:503", "belongsTo", "S-01"], ["fine:77", "belongsTo", "S-02"],
  ["P-2001", "worksAt", "S-02"], ["Y-3001", "manages", "S-02"],
  ["list:9", "sharedWith", "U-1002"], ["list:9", "sharedWith", "group:reading-club"],
  ["U-1006", "memberOf", "group:reading-club"],
];
const SUBJECTS = {
  "U-1001": { role: "member" }, "U-1002": { role: "member" }, "U-1005": { role: "member" },
  "P-2001": { role: "clerk" }, "Y-3001": { role: "branch_manager" },
};

// --- Policy document: data, not code. Kept and versioned separately. ---
const POLICY = {
  version: "2026-03-01",
  rules: [
    { name: "views own record", effect: "allow", action: ["view"],
      object: ["loan", "list"], relation: [["owner"]] },
    { name: "branch staff", effect: "allow", action: ["view", "update", "create"],
      object: ["loan"], role: ["clerk", "branch_manager"],
      relation: [["worksAt", "~belongsTo"], ["manages", "~belongsTo"]] },
    { name: "manager deletes fine", effect: "allow", action: ["delete"], object: ["fine"],
      role: ["branch_manager"], relation: [["manages", "~belongsTo"]] },
    { name: "shared list", effect: "allow", action: ["view"], object: ["list"],
      relation: [["~sharedWith"], ["memberOf", "~sharedWith"]] },
    { name: "off-hours write forbidden", effect: "deny", action: ["update", "create", "delete"],
      object: ["loan", "fine", "list"], outsideHours: ["08:30", "18:00"],
      exceptRole: ["branch_manager"] },
  ],
};

// --- Engine: interprets the policy, carries no domain knowledge. ---
function step(nodes, relation) {
  const reversed = relation.startsWith("~"), name = reversed ? relation.slice(1) : relation;
  const result = new Set();
  for (const [s, r, t] of EDGES) {
    if (r !== name) continue;
    if (!reversed && nodes.has(s)) result.add(t);
    if (reversed && nodes.has(t)) result.add(s);
  }
  return result;
}

const reaches = (subject, patterns, object) => patterns.some((pattern) => {
  let frontier = new Set([subject]);
  for (const r of pattern) frontier = step(frontier, r);
  return frontier.has(object);
});

function matches(rule, { subject, action, object, time }) {
  const role = SUBJECTS[subject]?.role;
  if (!rule.action.includes(action)) return false;
  if (!rule.object.includes(object.split(":")[0])) return false;
  if (rule.role && !rule.role.includes(role)) return false;
  if (rule.exceptRole && rule.exceptRole.includes(role)) return false;
  if (rule.outsideHours) {
    const [start, end] = rule.outsideHours;
    if (time >= start && time <= end) return false;
  }
  if (rule.relation && !reaches(subject, rule.relation, object)) return false;
  return true;
}

function decide(request) {
  const matched = POLICY.rules.filter((r) => matches(r, request));
  const deny = matched.find((r) => r.effect === "deny");
  if (deny) return [false, deny.name + " (deny wins)"];
  const allow = matched.find((r) => r.effect === "allow");
  return allow ? [true, allow.name] : [false, "no matching rule (default deny)"];
}

const SCENARIOS = [
  ["S1", "U-1001", "view",   "loan:501", "10:15", true],
  ["S2", "U-1001", "view",   "loan:502", "10:16", false],
  ["S3", "P-2001", "view",   "loan:502", "10:20", true],
  ["S4", "P-2001", "view",   "loan:503", "10:21", false],
  ["S5", "P-2001", "update", "loan:502", "22:40", false],
  ["S6", "Y-3001", "delete", "fine:77",  "11:05", true],
  ["S7", "U-1002", "view",   "list:9",   "12:00", true],
  ["S8", "U-1005", "view",   "list:9",   "12:01", false],
];

console.log(`policy version: ${POLICY.version}, rule count: ${POLICY.rules.length}`);
console.log("\nscenario | policy   | expect | match | rule that decided");
console.log("---------|----------|--------|-------|--------------------------------");
let matched = 0;
for (const [name, subject, action, object, time, expected] of SCENARIOS) {
  const [d, reason] = decide({ subject, action, object, time });
  if (d === expected) matched++;
  console.log(`${name}       | ${(d ? "allow" : "deny").padEnd(8)} | ` +
    `${(expected ? "allow" : "deny").padEnd(6)} | ${(d === expected ? "+" : "-").padEnd(5)} | ${reason}`);
}
console.log(`\npolicy engine correct decisions: ${matched}/${SCENARIOS.length}`);
EOF
node policy.mjs
```

```text
policy version: 2026-03-01, rule count: 5

scenario | policy   | expect | match | rule that decided
---------|----------|--------|-------|--------------------------------
S1       | allow    | allow  | +     | views own record
S2       | deny     | deny   | +     | no matching rule (default deny)
S3       | allow    | allow  | +     | branch staff
S4       | deny     | deny   | +     | no matching rule (default deny)
S5       | deny     | deny   | +     | off-hours write forbidden (deny wins)
S6       | allow    | allow  | +     | manager deletes fine
S7       | allow    | allow  | +     | shared list
S8       | deny     | deny   | +     | no matching rule (default deny)

policy engine correct decisions: 8/8
```

All eight decisions are correct. The gain does not come from a new model — it comes
from three models' expressive power joining in a single document: the `role` field
carries the role-based constraint, `outsideHours` the attribute-based one, `relation`
the relationship-based one. The engine sees these not as separate concepts but as
fields of the same rule.

Two design decisions are written into the document. **Default deny**: if no rule
matches, the decision is deny. **Deny priority**: if one of the matching rules says
deny, the ones that say allow do not count. Together, they guarantee that adding a new
rule to the policy can never quietly loosen an existing prohibition.

#### Comparing the Four Models

The block below runs all four models on the same set and lines the decisions up side by
side.

```bash
cat > comparison.mjs <<'EOF'
const SUBJECTS = {
  "U-1001": { role: "member", branch: "S-02" }, "U-1002": { role: "member", branch: "S-02" },
  "U-1005": { role: "member", branch: "S-01" }, "P-2001": { role: "clerk", branch: "S-02" },
  "Y-3001": { role: "branch_manager", branch: "S-02" },
};
const EDGES = [
  ["U-1001", "owner", "loan:501"], ["U-1002", "owner", "loan:502"],
  ["U-1005", "owner", "loan:503"], ["U-1001", "owner", "list:9"],
  ["loan:501", "belongsTo", "S-02"], ["loan:502", "belongsTo", "S-02"],
  ["loan:503", "belongsTo", "S-01"], ["fine:77", "belongsTo", "S-02"],
  ["P-2001", "worksAt", "S-02"], ["Y-3001", "manages", "S-02"],
  ["list:9", "sharedWith", "U-1002"],
];
const SCENARIOS = [
  ["S1", "U-1001", "view",   "loan:501", { owner: "U-1001", branch: "S-02" }, "10:15", true],
  ["S2", "U-1001", "view",   "loan:502", { owner: "U-1002", branch: "S-02" }, "10:16", false],
  ["S3", "P-2001", "view",   "loan:502", { owner: "U-1002", branch: "S-02" }, "10:20", true],
  ["S4", "P-2001", "view",   "loan:503", { owner: "U-1005", branch: "S-01" }, "10:21", false],
  ["S5", "P-2001", "update", "loan:502", { owner: "U-1002", branch: "S-02" }, "22:40", false],
  ["S6", "Y-3001", "delete", "fine:77",  { owner: "U-1002", branch: "S-02" }, "11:05", true],
  ["S7", "U-1002", "view",   "list:9",   { owner: "U-1001", branch: "S-02" }, "12:00", true],
  ["S8", "U-1005", "view",   "list:9",   { owner: "U-1001", branch: "S-02" }, "12:01", false],
];
const type = (n) => n.split(":")[0];
const duringWorkingHours = (t) => t >= "08:30" && t <= "18:00";
const WRITE_ACTIONS = ["update", "create", "delete"];

// 1. Role-based: the decision looks only at the role and the resource type.
const PERMISSIONS = {
  member: ["view:loan", "view:list"],
  clerk: ["view:loan", "view:list", "update:loan", "create:loan"],
  branch_manager: ["view:loan", "view:list", "update:loan",
                   "create:loan", "delete:fine", "delete:loan"],
};
const rbac = (o, e, n) => PERMISSIONS[SUBJECTS[o].role].includes(`${e}:${type(n)}`);

// 2. Attribute-based: ownership, branch, and time comparisons.
function abac(o, e, n, attrs, time) {
  const s = SUBJECTS[o];
  if (WRITE_ACTIONS.includes(e) && !duringWorkingHours(time) && s.role !== "branch_manager") return false;
  if (e === "view" && attrs.owner === o) return true;
  if (["clerk", "branch_manager"].includes(s.role) && attrs.branch === s.branch &&
      ["view", "update", "create"].includes(e)) return true;
  return s.role === "branch_manager" && attrs.branch === s.branch && e === "delete" && type(n) === "fine";
}

// 3. Relationship-based: a path pattern over the graph.
const PATTERNS = {
  "view:loan": [["owner"], ["worksAt", "~belongsTo"], ["manages", "~belongsTo"]],
  "update:loan": [["worksAt", "~belongsTo"], ["manages", "~belongsTo"]],
  "delete:fine": [["manages", "~belongsTo"]],
  "view:list": [["owner"], ["~sharedWith"]],
};
function step(set, r) {
  const reversed = r.startsWith("~"), name = reversed ? r.slice(1) : r, c = new Set();
  for (const [s, i, t] of EDGES) {
    if (i !== name) continue;
    if (!reversed && set.has(s)) c.add(t);
    if (reversed && set.has(t)) c.add(s);
  }
  return c;
}
const reaches = (o, patterns, n) => (patterns ?? []).some((p) => {
  let c = new Set([o]);
  for (const r of p) c = step(c, r);
  return c.has(n);
});
const rebac = (o, e, n) => reaches(o, PATTERNS[`${e}:${type(n)}`], n);

// 4. Policy-based: role, relation, and context in a single document.
const POLICY = [
  { effect: "deny", action: WRITE_ACTIONS, outsideHours: ["08:30", "18:00"], exceptRole: ["branch_manager"] },
  { effect: "allow", action: ["view"], pattern: [["owner"], ["~sharedWith"]] },
  { effect: "allow", action: ["view", "update", "create"],
    role: ["clerk", "branch_manager"], pattern: [["worksAt", "~belongsTo"], ["manages", "~belongsTo"]] },
  { effect: "allow", action: ["delete"], role: ["branch_manager"], pattern: [["manages", "~belongsTo"]] },
];
function policy(o, e, n, attrs, time) {
  const role = SUBJECTS[o].role;
  const matched = POLICY.filter((r) =>
    r.action.includes(e) &&
    !(r.role && !r.role.includes(role)) &&
    !(r.exceptRole && r.exceptRole.includes(role)) &&
    !(r.outsideHours && time >= r.outsideHours[0] && time <= r.outsideHours[1]) &&
    !(r.pattern && !reaches(o, r.pattern, n)));
  if (matched.some((r) => r.effect === "deny")) return false;
  return matched.some((r) => r.effect === "allow");
}

const MODELS = [["RBAC", rbac], ["ABAC", abac], ["ReBAC", rebac], ["policy", policy]];
const counter = new Map(MODELS.map(([name]) => [name, 0]));
const separator = "---------|" + MODELS.map(() => "---------").join("|") + "|-------";

console.log("scenario | " + MODELS.map(([a]) => a.padEnd(8)).join("| ") + "| expect");
console.log(separator);
for (const [name, o, e, n, attrs, time, expected] of SCENARIOS) {
  const cells = MODELS.map(([m, fn]) => {
    const d = fn(o, e, n, attrs, time);
    if (d === expected) counter.set(m, counter.get(m) + 1);
    return `${d ? "allow" : "deny "} ${d === expected ? "+" : "-"}`.padEnd(8);
  });
  console.log(`${name}       | ${cells.join("| ")}| ${expected ? "allow" : "deny"}`);
}
console.log(separator);
console.log("correct  | " + MODELS.map(([m]) => `${counter.get(m)}/8`.padEnd(8)).join("| ") + "|");
EOF
node comparison.mjs
```

```text
scenario | RBAC    | ABAC    | ReBAC   | policy  | expect
---------|---------|---------|---------|---------|-------
S1       | allow + | allow + | allow + | allow + | allow
S2       | allow - | deny  + | deny  + | deny  + | deny
S3       | allow + | allow + | allow + | allow + | allow
S4       | allow - | deny  + | deny  + | deny  + | deny
S5       | allow - | deny  + | allow - | deny  + | deny
S6       | allow + | allow + | allow + | allow + | allow
S7       | allow + | deny  - | allow + | allow + | allow
S8       | allow - | deny  + | deny  + | deny  + | deny
---------|---------|---------|---------|---------|-------
correct  | 4/8     | 7/8     | 7/8     | 8/8     |
```

How the table should be read matters. It should not be concluded that the column on
the right is "better" than the one on the left; the conclusion to draw is that each
model can express a different kind of constraint. The role-based model's four correct
answers show that when the decision ends at the resource type, no other mechanism is
needed.

The S5 and S7 columns read the models' limits in the same row: the attribute-based
model solves S5 but not S7; the relationship-based model does the opposite. The policy
column solves both, because the rule document carries both kinds of constraint as
fields.

#### Operating the Policy

The policy being a separate document means it produces a deployment object outside the
code, and that has a discipline of its own.

**The policy is versioned and logged together with the decision.** The line written to
the log carries the decision itself and the name of the rule that gave it. The answer
to "why was this request denied" can be given months later too.

**A change is tested in report mode.** The same split established for the content
security policy in the Client-Side Security topic applies here too: the new policy
produces decisions against real traffic, but the decision is not enforced; only the
difference from the policy currently in force is recorded. If the diff list contains
only the expected changes, the policy switches to enforcement mode.

**The scenario set is the policy's test.** This lesson's table is a test harness; it is
run with every policy change, and a minus appearing in the match column stops the
change. A rule the policy allows and a rule it denies are added to the set with every
new rule.

**The decision data is moved to the decision point.** The engine needs access to the
relationship graph and the attribute values. If this data is queried at decision time,
every request carries an extra delay; if it is copied in advance, a freshness problem
arises. The criterion is the same as the one in the attribute-freshness lesson: data
that changes the decision and that can change is read at decision time.

#### Summary

- Policy-based authorization moves access rules out of application code into an
  interpreted document; the decision point interprets the policy, and the enforcement
  point applies the decision.
- The document can carry role, attribute, and relationship constraints as fields of the
  same rule, so the expressive power of three models joins together.
- Default deny and deny priority together keep a new rule from quietly loosening an
  existing prohibition.
- On the same scenario set, the four models produce 4, 7, 7, and 8 correct decisions in
  turn; the difference is not the model's quality but the kind of constraint it can
  express.
- The policy is versioned, logged together with the decision's reason, and changes are
  tested in report mode before being switched to enforcement mode.

#### Next Step

All four models so far have made the decision through the identity of the person making
the request. But the one making the request is often not the person themselves — it is
a client the person has authorized: the library's mobile app, a reporting tool, a
third-party reading-list service. A person's authority and the authority granted to a
client are not the same thing. The next lesson takes up this second layer — token
scopes — and shows how scopes map onto endpoints.

### Scopes and Permissions

Source: https://academia.sh/en/courses/authentication-and-authorization/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.

### Multi-Tenant Authorization

Source: https://academia.sh/en/courses/authentication-and-authorization/multi-tenant-authorization

Carrying the tenant boundary into the query, measuring the leak produced when the filter is forgotten, taking the tenant identity from the authenticated context, three-layer defense, and physical placement options for tenant data.

The previous lesson used scope to narrow a client's authority. In every example, there
was a single library behind the scenes: one set of members, one staff roster, one pool
of records. Once the same server serves more than one institution, a new boundary
enters the picture.

The difference of this boundary from the previous models is that it is drawn by
**data**. The North Library's clerk can see all of their own institution's records; the
South Library's clerk in the same role can see their own institution's records too.
Their role, permissions, and token scopes are identical. The only thing that separates
them is which rows the query touches.

#### Isolation Is Built by the Query, Not the Role

In a multi-tenant system, every record belongs to a **tenant**. The tenant is the
institution that owns the data, and it sits in the schema as a column. When the
authorization check does not look at this column, a request that passes the role check
returns a neighboring institution's record.

The computation below runs two repository implementations over the same data: one has
forgotten the tenant filter, the other keeps it mandatory on every query.

```bash
cat > tenant.mjs <<'EOF'
// tenant.mjs — carrying tenant isolation into the query and auditing it
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE member (member_id TEXT PRIMARY KEY, tenant TEXT NOT NULL, name TEXT NOT NULL);
  CREATE TABLE loan (loan_id TEXT PRIMARY KEY, tenant TEXT NOT NULL, member_id TEXT NOT NULL,
                      isbn TEXT NOT NULL, status TEXT NOT NULL);
  INSERT INTO member VALUES ('U-1','north','Ella Novak'), ('U-2','north','Marcus Lee'),
                         ('U-9','south','Priya Kapoor');
  INSERT INTO loan VALUES ('O-1','north','U-1','978-0201896831','OPEN'),
                           ('O-2','north','U-2','978-0262033848','CLOSED'),
                           ('O-7','south','U-9','978-0131103627','OPEN'),
                           ('O-8','south','U-9','978-0201896831','OPEN');
`);

// 1. Repo that forgot the tenant filter: only the status condition exists.
const looseRepo = {
  name: "no tenant filter",
  openLoans: () => db.prepare("SELECT loan_id, tenant FROM loan WHERE status = 'OPEN'").all(),
  findMember: (id) => db.prepare("SELECT member_id, tenant, name FROM member WHERE member_id = ?").all(id),
};

// 2. Repo with a mandatory tenant filter: every query takes the tenant from context.
const strictRepo = {
  name: "tenant filter required",
  openLoans: (t) => db.prepare("SELECT loan_id, tenant FROM loan WHERE tenant = ? AND status = 'OPEN'").all(t),
  findMember: (t, id) => db.prepare("SELECT member_id, tenant, name FROM member WHERE tenant = ? AND member_id = ?").all(t, id),
};

const TENANT = "north";                       // the tenant of the staff member making the request
const leaked = (rows) => rows.filter((r) => r.tenant !== TENANT).length;

console.log("repo                    query             returned  leaked");
for (const [repo, rows] of [
  [looseRepo, looseRepo.openLoans()],
  [strictRepo, strictRepo.openLoans(TENANT)],
]) {
  console.log(`${repo.name.padEnd(23)} ${"openLoans".padEnd(17)} ${String(rows.length).padStart(5)} ${String(leaked(rows)).padStart(6)}`);
}
for (const [repo, rows] of [
  [looseRepo, looseRepo.findMember("U-9")],
  [strictRepo, strictRepo.findMember(TENANT, "U-9")],
]) {
  console.log(`${repo.name.padEnd(23)} ${"findMember('U-9')".padEnd(17)} ${String(rows.length).padStart(5)} ${String(leaked(rows)).padStart(6)}`);
}

// 3. The source of the tenant: from the request, or from the token?
function resolveTenant(source, token, request) {
  return source === "request" ? request.tenant : token.tenant;
}
const token = { subject: "P-2001", tenant: "north" };
console.log("\ntenant source    value in request  used        rows returned  leaked");
for (const source of ["request", "token"]) {
  const request = { tenant: "south", member_id: "U-9" };       // the client writes a different tenant
  const t = resolveTenant(source, token, request);
  const rows = strictRepo.findMember(t, request.member_id);
  console.log(`${source.padEnd(16)} ${request.tenant.padEnd(17)} ${t.padEnd(11)} ${String(rows.length).padStart(11)} ${String(leaked(rows)).padStart(6)}`);
}

// 4. Static audit: do queries reaching tenant-carrying tables include the filter?
const TENANT_TABLES = ["member", "loan"];
const QUERIES = [
  { name: "openLoans/loose",   sql: "SELECT loan_id FROM loan WHERE status = 'OPEN'" },
  { name: "openLoans/strict",  sql: "SELECT loan_id FROM loan WHERE tenant = ? AND status = 'OPEN'" },
  { name: "findMember/strict", sql: "SELECT name FROM member WHERE tenant = ? AND member_id = ?" },
  { name: "count",             sql: "SELECT count(*) FROM loan" },
  { name: "book list",         sql: "SELECT isbn FROM book" },
];
console.log("\nquery                  table   tenanted  filter  finding");
let findings = 0;
for (const q of QUERIES) {
  const table = /FROM\s+(\w+)/i.exec(q.sql)?.[1] ?? "-";
  const hasTenantCol = TENANT_TABLES.includes(table);
  const hasFilter = /\btenant\s*=\s*\?/i.test(q.sql);
  const flagged = hasTenantCol && !hasFilter;
  if (flagged) findings++;
  console.log(`${q.name.padEnd(22)} ${table.padEnd(7)} ${(hasTenantCol ? "yes" : "no").padEnd(9)} ${(hasFilter ? "yes" : "no").padEnd(7)} ${flagged ? "MISSING FILTER" : "-"}`);
}
console.log(`audit findings: ${findings} / ${QUERIES.length}`);
EOF
node tenant.mjs
```

```text
repo                    query             returned  leaked
no tenant filter        openLoans             3      2
tenant filter required  openLoans             1      0
no tenant filter        findMember('U-9')     1      1
tenant filter required  findMember('U-9')     0      0

tenant source    value in request  used        rows returned  leaked
request          south             south                 1      1
token            south             north                 0      0

query                  table   tenanted  filter  finding
openLoans/loose        loan    yes       no      MISSING FILTER
openLoans/strict       loan    yes       yes     -
findMember/strict      member  yes       yes     -
count                  loan    yes       no      MISSING FILTER
book list              book    no        no      -
audit findings: 2 / 5
```

#### The Observable Consequence of a Forgotten Filter

The first part of the output counts the leak. The filterless repo returns three records
for the open-loans query, and two of them belong to a different tenant. The member-lookup
query is more striking: the requested identifier belongs to a neighboring institution's
member, and the filterless query returns that record — the role check passed, because
the clerk does have "read member" permission. The permission is correct; what is wrong
is that **which rows** the permission applies to was never asked.

The filtered repo returns zero leaks for the same two queries. The second query's result
is empty: the record exists, but not for this tenant. The choice between an empty result
and an unauthorized response is a design decision, and an **empty result** is usually
preferred — even the information "this identifier exists at a different institution" is
a cross-tenant leak.

#### Where the Tenant Identity Comes From

The second part shows a more subtle bug. If the tenant identity is read from the
request's body or a path parameter, the client can change it: the request writes
`south`, the query uses that tenant, and a neighboring institution's record comes back.
The same request returns zero records when the tenant identity is taken **from the
token** instead.

The rule is one sentence: the tenant identity comes from the authenticated context; a
tenant value coming from the request can be, at most, a **filter**, and can never be
the source of authority. If the tenant the client sends conflicts with the tenant in the
context, the request is denied — and this conflict is also an event worth logging.

#### Making the Filter Hard to Forget

The third part writes an audit: do queries reaching tables that carry a tenant column
include the filter? Two of the five queries in the model produced a finding — one is
the deliberately loose repo, the other looks like an innocent count query. The count
query returns a single number, but that number is the total across all tenants; on a
dashboard, this leaks a neighboring institution's business volume.

The audit's value is that it catches forgetting **structurally**. The same job is done
by three layers together, and each one catches what the other misses:

1. **The repo layer**: a query with no tenant parameter cannot be written — the function
   that builds the query takes the tenant as a mandatory argument.
2. **The audit script**: newly written queries are scanned in continuous integration; a
   query without a filter fails the build.
3. **The database layer**: a row-level security policy filters rows even if the
   application layer makes a mistake. This is the application-side counterpart of the
   Row-Level Security lesson in the Relational Database Administration course.

All three are needed. Relying on the repo layer alone breaks in a reporting script that
writes raw queries; relying on the database layer alone breaks in a batch job that never
binds a tenant context to its session.

#### Physical Placement of Tenant Data

Isolation has three placement forms, and the choice criterion is the same three
questions: the likelihood of accidentally reaching a neighboring tenant's data, cost per
tenant, and the granularity of migration and backup.

**Shared table** (the model in this lesson) is the cheapest and demands the most care:
every record sits in the same table, and the query provides isolation. **Schema per
tenant** moves the filter to the connection level; connecting to the wrong schema is
still possible, but a lapse inside the query becomes harmless. **Database per tenant**
gives the strongest isolation; its cost is that migration and backup work is multiplied
by the tenant count.

The decision does not have to be made once: small tenants can live in a shared table
while large tenants whose contract requires isolation are kept in a separate database.
In that case, the application layer must see both placements behind the same interface.

#### Summary

- In a multi-tenant system, the boundary is drawn by data, not by role or scope; two
  clerks with the same role see different row sets.
- When the tenant filter is forgotten, a request that passes the role check returns a
  neighboring institution's record; in the model, two of the three rows from one query
  leaked, and the filtered version leaked none.
- The tenant identity comes from the authenticated context; a value coming from the
  request cannot be the source of authority — in the model, a tenant read from the
  request opened a neighboring institution's record.
- Forgetting the filter is made harder with three layers: a repo that makes the tenant
  a mandatory argument, an audit that scans queries, and a row-level security policy.
- Physical placement (shared table, schema per tenant, database per tenant) is a
  trade-off between isolation strength and cost per tenant.

#### Next Step

The tenant boundary determined **which set** a request can touch. Within that set,
individual objects still exist: two members of the same library must not see each
other's loan record. The course's last lesson takes up this final boundary — what
happens when a different identifier is written into the address bar, and where object
ownership must be checked.

### Object-Level Authorization

Source: https://academia.sh/en/courses/authentication-and-authorization/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?

