---
title: 'Authentication and Authorization Distinction'
source: 'https://academia.sh/en/courses/authentication-and-authorization/authentication-and-authorization-distinction'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:31+00:00'
license: 'CC BY-SA 4.0'
---

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