---
title: 'Token-Based Authentication'
source: 'https://academia.sh/en/courses/authentication-and-authorization/token-based-authentication'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:32+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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

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

```js
// 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());
```

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