Skip to content
academia.sh

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-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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close