Skip to content
academia.sh

Lesson 04 / 23

Token-Based Authentication

Writing the principal into the signed value the request carries, and verification coming down to a local computation: the distinction between opaque and self-contained tokens, the bearer scheme, what the signature guarantees, and measuring that logout is not immediately effective.

Contents

The session store carried a cost: every request required reading the store, and when the application scaled horizontally, the store had to live somewhere every process could reach. The source of the cost was that the value the request carried was meaningless; the work of making sense of the identity always sat with the server.

This lesson builds the reverse. Who the principal is and what they are authorized to do is written into the value the request carries, and that value is signed to make sure it was not altered. During verification, the server looks at no record — it only computes the signature. The gain is measurable, and so is the loss: if there is no record, what does logout delete?

The Two Kinds of Token

An opaque token is a string whose carried value itself contains no meaning. The previous lesson’s session identifier is of this kind: the value points to a record and says nothing without that record. A service that receives the token learns whether it is valid and who the principal behind it is by asking the service that issued it — this is called token introspection.

A self-contained token carries the information about the principal within itself and is protected by a signature. If the verifying party can compute the signature, it asks nowhere. This kind’s gain is that verification is local: the loan service, the catalog, and the fine service can each verify the same token separately, and no session store shared between them is required.

The distinction is a trade-off. With an opaque token, the validity decision stays in the issuer’s hands and can be changed at any moment; in exchange, every verification requires a query. With a self-contained token, verification is cheap; in exchange, the decision is frozen at the moment the token is issued.

Issuing and Verifying a Signed Token

The server below encodes the body with base64url and appends an HMAC-SHA256 signature next to it. This format is specific to this lesson; the next lesson builds the standard format that does the same job.

// token.mjs — stateless authentication with a self-contained, signed token
// Usage: node token.mjs <port> [revoke]
//   revoke: at logout, the token identifier is written to the revocation list
// Env: LIFETIME_MS (token lifetime, default 6000)
import { createServer } from "node:http";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8531);
const HAS_REVOCATION_LIST = process.argv.includes("revoke");
const LIFETIME_MS = Number(process.env.LIFETIME_MS ?? 6000);

const KEY = randomBytes(32);                    // signing key; stays on the server
const ACCOUNTS = new Map([["clara.diaz", { password: "café-books-1876", code: "U-1001", role: "member" }]]);

// Revocation list: token identifier -> how long it is kept
const REVOKED = new Map();
let verificationCount = 0, storeReadCount = 0;

const b64 = (v) => Buffer.from(v).toString("base64url");
const sign = (body) => createHmac("sha256", KEY).update(body).digest("base64url");

const issue = (principal) => {
  const body = b64(JSON.stringify({ ...principal, jti: randomBytes(9).toString("base64url"), exp: Date.now() + LIFETIME_MS }));
  return `${body}.${sign(body)}`;
};

const verify = (token) => {
  verificationCount++;
  const [body, signature] = (token ?? "").split(".");
  if (!body || !signature) return { error: "format" };

  const expected = Buffer.from(sign(body), "utf8");
  const given = Buffer.from(signature, "utf8");
  if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { error: "signature" };

  const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
  if (Date.now() >= claims.exp) return { error: "expired" };

  if (HAS_REVOCATION_LIST) {
    storeReadCount++;                              // the one point where statelessness is given up
    if (REVOKED.has(claims.jti)) return { error: "revoked" };
  }
  return { claims };
};

const readBody = (request) =>
  new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(v)); });

createServer(async (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));
  };

  if (request.method === "POST" && path === "/login") {
    const { username, password } = JSON.parse((await readBody(request)) || "{}");
    const account = ACCOUNTS.get(username ?? "");
    if (!account || password !== account.password) return json(401, { error: "could not authenticate" });
    return json(200, { token: issue({ code: account.code, role: account.role }) });
  }

  const bearer = (request.headers.authorization ?? "").startsWith("Bearer ")
    ? request.headers.authorization.slice(7) : null;

  if (request.method === "POST" && path === "/logout") {
    const result = verify(bearer);
    if (result.claims && HAS_REVOCATION_LIST) REVOKED.set(result.claims.jti, result.claims.exp);
    return json(200, { loggedOut: true, hasRevocationList: HAS_REVOCATION_LIST });
  }

  if (path === "/me") {
    if (!bearer) {
      return json(401, { error: "no token" }, { "www-authenticate": 'Bearer realm="loans"' });
    }
    const result = verify(bearer);
    if (result.error) {
      return json(401, { error: result.error },
        { "www-authenticate": `Bearer realm="loans", error="invalid_token", error_description="${result.error}"` });
    }
    return json(200, { code: result.claims.code, role: result.claims.role });
  }

  if (path === "/diagnostics") {
    // Expired records are dropped from the list: the list holds not every token,
    // only the ones whose lifetime has not run out.
    for (const [jti, exp] of REVOKED) if (Date.now() >= exp) REVOKED.delete(jti);
    return json(200, { verificationCount, storeReadCount, revocationListLength: REVOKED.size });
  }
  json(404, { error: "no such resource" });
}).listen(PORT, "127.0.0.1", () => console.log(`token 127.0.0.1:${PORT} revoke=${HAS_REVOCATION_LIST}`));

The token is sent in the Authorization header with the bearer scheme. The scheme’s name is meaningful: whoever carries the token owns it. A party that gets hold of the header needs no password besides it; this creates the same transport requirement as the basic scheme’s situation and again rules out an unencrypted connection.

The rejection response states what the problem is in the WWW-Authenticate header.

LIFETIME_MS=60000 node token.mjs 8533 > /dev/null & SERVER=$!
sleep 1
curl -s -D - -o /dev/null -H "Authorization: Bearer broken.value" http://127.0.0.1:8533/me | grep -i "^www-authenticate"
kill $SERVER
www-authenticate: Bearer realm="loans", error="invalid_token", error_description="signature"

The Readability of the Body

The signature guarantees that the body is in the state it left the server in. It does not hide the body. The encoding is base64url, and as seen in the previous lesson, decoding it requires no key.

// payload.mjs — the readability of the body carried by the token, and what the signature verifies
// Usage: while LIFETIME_MS=60000 node token.mjs 8533 &  is running,  node payload.mjs 8533
const PORT = Number(process.argv[2] ?? 8533);
const BASE = `http://127.0.0.1:${PORT}`;

const login = await fetch(`${BASE}/login`, {
  method: "POST", headers: { "content-type": "application/json" },
  body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
});
const { token } = await login.json();
const [body, signature] = token.split(".");

console.log("body      (base64url):", body);
console.log("signature (base64url):", signature);
console.log("body decoded:", Buffer.from(body, "base64url").toString("utf8"));

const ask = async (value) =>
  (await fetch(`${BASE}/me`, { headers: { authorization: `Bearer ${value}` } })).status;

// Body is altered, signature is left as is: a body that makes the role "staff"
const altered = Buffer.from(JSON.stringify({ code: "U-1001", role: "staff", jti: "x", exp: Date.now() + 60000 }))
  .toString("base64url");

console.log("unchanged        :", await ask(token));
console.log("body altered     :", await ask(`${altered}.${signature}`));
console.log("signature dropped:", await ask(body));
LIFETIME_MS=60000 node token.mjs 8533 > /dev/null & SERVER=$!
sleep 1
node payload.mjs 8533
kill $SERVER
body      (base64url): eyJjb2RlIjoiVS0xMDAxIiwicm9sZSI6Im1lbWJlciIsImp0aSI6IlJpX0xFNVg1TFZqZyIsImV4cCI6MTc4NzA3ODYwNjA4Mn0
signature (base64url): UJSKObr4iiXUdLW-RErFtEFhNJbDr9RiCK4_lbnwNEU
body decoded: {"code":"U-1001","role":"member","jti":"Ri_LE5X5LVjg","exp":1787078606082}
unchanged        : 200
body altered     : 401
signature dropped: 401

The token identifier and timestamp change on every run. What the three lines say must be read separately. The body is readable: every field inside it is open to anyone who gets hold of the token. This is why a password, an ID number, an internal table key, or any other secret is never written into the body. Altering the body, on the other hand, produces no result: a body that makes the role staff has no chance of matching the old signature, and the server returns a 401. Dropping the signature leads to the same outcome — a body without a signature cannot pass the format check.

In short, the signature provides integrity, not confidentiality. Confusing the two is the source of setups that write secrets into the body.

The Revocation Problem

Because verification looks at no record, there is no place where the server can say “this token is no longer valid.” A logout request can delete the token from the client, but if another copy exists, that copy keeps working. The duration can be measured: the token lifetime is pulled down to 6 seconds, /me is asked continuously after logout, and the time elapsed until the first failure is recorded.

// revocation-measurement.mjs — measures how many seconds the token stays valid after logout
// Usage: LIFETIME_MS=6000 node token.mjs 8531 &         (no revocation list)
//        LIFETIME_MS=6000 node token.mjs 8532 revoke &  (with revocation list)
//        node revocation-measurement.mjs 8531   /   node revocation-measurement.mjs 8532
const PORT = Number(process.argv[2]);
const BASE = `http://127.0.0.1:${PORT}`;

const login = await fetch(`${BASE}/login`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
});
const { token } = await login.json();
const headers = { authorization: `Bearer ${token}` };
const status = async () => (await fetch(`${BASE}/me`, { headers })).status;

console.log("before logout /me :", await status());
await fetch(`${BASE}/logout`, { method: "POST", headers });

const logoutTime = Date.now();
let stayedValid = 0;
for (;;) {
  const code = await status();
  if (code !== 200) break;
  stayedValid = Date.now() - logoutTime;
  await new Promise((c) => setTimeout(c, 250));
}
console.log("time it stayed valid after logout:", (stayedValid / 1000).toFixed(1), "s");
console.log("diagnostics:", await (await fetch(`${BASE}/diagnostics`)).json());
LIFETIME_MS=6000 node token.mjs 8531 > /dev/null & UNLISTED=$!
LIFETIME_MS=6000 node token.mjs 8532 revoke > /dev/null & LISTED=$!
sleep 1
echo "— no revocation list —"
node revocation-measurement.mjs 8531
echo "— with revocation list —"
node revocation-measurement.mjs 8532
kill $UNLISTED $LISTED
— no revocation list —
before logout /me : 200
time it stayed valid after logout: 5.8 s
diagnostics: { verificationCount: 27, storeReadCount: 0, revocationListLength: 0 }
— with revocation list —
before logout /me : 200
time it stayed valid after logout: 0.0 s
diagnostics: { verificationCount: 3, storeReadCount: 3, revocationListLength: 1 }

The measured durations move by a few percent; what matters is the order of magnitude. In the setup without a list, the logout request’s server-side counterpart is empty: the token keeps working until the end of its lifetime. At a six-second lifetime this is six seconds; at an eight-hour lifetime it is eight hours. The previous lesson’s session store gave zero for the same measurement, because the decision was deleting a record.

The same output also shows where the gain comes from. In the setup without a list, storeReadCount is zero: twenty-seven verifications were done and none of them asked anywhere. In the setup with a list, revocationListLength rose to one; the single record written at logout stays there until its lifetime runs out.

The Cost of the Revocation List

The second setup writes the token identifier to a revocation list at logout and checks that list on every verification. The measurement gives zero seconds: the decision is effective immediately. But storeReadCount is now equal to the verification count — statelessness is given up at this point.

Still, a quantitative difference remains between the two setups. The session store holds every open session and a record stays until the user logs out or the lifetime runs out. The revocation list holds only tokens that are revoked and not yet expired; a record whose lifetime runs out drops from the list, because the expiration check would reject it anyway. This list shrinks as the token lifetime shortens.

This leads to a practical rule: for a stateless token, the solution to revocation is not growing the list but shortening the lifetime. When the lifetime shortens, the revocation window shortens with it. But a short lifetime forces the user to log in again every minute; the solution to this dilemma is a lesson of its own.

Summary

  • An opaque token points to a record and its verification requires a query; a self-contained token carries the information within itself and its verification is local.
  • A self-contained token is signed; the signature guarantees the body has not changed but does not hide the body, so no secret is written into the body.
  • The token is sent with the bearer scheme; the carrier being considered the owner again rules out an unencrypted connection, and the reason for rejection is stated in the WWW-Authenticate header.
  • Because there is no record on the server, logout is not effective immediately: in the measurement, the token kept working until its lifetime ran out.
  • A revocation list makes the decision effective immediately but ties every verification to a read; because the list holds only unexpired revocations, it is smaller than a session store.
  • In a stateless setup, the variable that determines the revocation window is the token lifetime; a short lifetime narrows the window, and that is where the real solution lies.

Next Step

The token format used in this lesson was specific to this lesson: body, dot, signature. A widespread standard exists that does the same job, and it fixes the fields’ names, how the signature algorithm is stated, and which unit the duration is written in. Being a standard means verification is standardized too — and which steps the verifier must not skip matters more than the format itself. The next lesson builds this format in detail, generates and verifies the signature by hand, and shows why the verifier collapses at the point where it trusts what the token says about itself.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close