Skip to content
academia.sh

Lesson 02 / 23

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.

Contents

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.

// 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}`));
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.

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

// 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");
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.

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close