Lesson 01 / 23
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.
Contents
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.
// 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.
// 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.
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.
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.
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.
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-Authenticateheader; 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.