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

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