Skip to content
academia.sh

Lesson 03 / 23

Session-Based Authentication

Building a server-held session record and carrying the session identifier with a cookie: verifying cookie attributes in the response header, measuring identifier renewal at login, logout taking immediate effect, and idle and absolute lifetime limits.

Contents

The common source of the problems the basic scheme does not solve was that the server remembered nothing between requests. The password passed again on every request, there was no operation called logout, and the same account’s different clients could not be told apart. If the server authenticates once and keeps the result on its own side, all three change.

The structure built in this lesson is the session: a record is opened on the server at login, the record is given a random session identifier, and the client carries only this identifier on subsequent requests. The password no longer goes out over the network. The identifier itself carries no meaning; its only function is to point to the record on the server. The two details that determine whether the setup is correct are which attributes the identifier is carried with, and whether it is renewed at login.

The Session Store

The server below holds a session store, opens a record at login, sets a cookie, and on every request turns the cookie’s identifier into a record. It takes two flags: secure sends the cookie for a setup behind TLS; fixed produces the wrong setup that does not renew the identifier at login, and is this lesson’s counter-example.

// session.mjs — server-side session store and the session identifier carried by a cookie
// Usage: node session.mjs <port> [secure] [fixed]
//   secure: cookie is sent with the __Host- prefix and the Secure attribute (TLS setup)
//   fixed : session identifier is NOT renewed at login (wrong setup; this lesson's counter-example)
// Env: IDLE_MS (idle timeout), ABSOLUTE_MS (absolute lifetime)
import { createServer } from "node:http";
import { randomBytes, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8521);
const SECURE = process.argv.includes("secure");
const FIXED = process.argv.includes("fixed");
const IDLE_MS = Number(process.env.IDLE_MS ?? 15 * 60_000);
const ABSOLUTE_MS = Number(process.env.ABSOLUTE_MS ?? 8 * 3_600_000);

const COOKIE_NAME = SECURE ? "__Host-loan_session" : "loan_session";
const ACCOUNTS = new Map([["clara.diaz", { password: "café-books-1876", code: "U-1001", role: "member" }]]);

// Session store: id -> record. A Map in a single process; a shared store across multiple processes.
const STORE = new Map();
const newId = () => randomBytes(32).toString("base64url");

const isEqual = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

const readCookie = (header, name) =>
  (header ?? "").split(";").map((p) => p.trim().split("="))
    .filter(([k]) => k === name).map(([, v]) => v)[0];

const writeCookie = (id, lifetimeSec) => {
  const parts = [`${COOKIE_NAME}=${id}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${lifetimeSec}`];
  if (SECURE) parts.splice(2, 0, "Secure");
  return parts.join("; ");
};

const resolveSession = (request) => {
  const id = readCookie(request.headers.cookie, COOKIE_NAME);
  if (!id) return { id: null, record: null };
  const record = STORE.get(id);
  if (!record) return { id, record: null };
  const now = Date.now();
  if (now - record.lastAccess > IDLE_MS || now - record.opened > ABSOLUTE_MS) {
    STORE.delete(id);                       // an expired record is dropped from the store
    return { id, record: null };
  }
  record.lastAccess = now;
  return { id, record };
};

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 || !isEqual(password ?? "", account.password)) return json(401, { error: "could not authenticate" });

    const { id: previousId } = resolveSession(request);
    let id;
    if (FIXED && previousId) {
      id = previousId;                   // wrong: pre-login identifier is retained
    } else {
      if (previousId) STORE.delete(previousId);   // correct: the old record is deleted
      id = newId();                               // correct: a new identifier is generated
    }
    STORE.set(id, { principal: { code: account.code, role: account.role }, opened: Date.now(), lastAccess: Date.now() });
    return json(200, { loggedInAs: account.code }, { "set-cookie": writeCookie(id, Math.floor(ABSOLUTE_MS / 1000)) });
  }

  if (request.method === "POST" && path === "/logout") {
    const { id } = resolveSession(request);
    if (id) STORE.delete(id);           // the record is deleted — the decision takes effect on the next request
    return json(200, { loggedOut: true }, { "set-cookie": writeCookie("", 0) });
  }

  if (path === "/me") {
    const { record } = resolveSession(request);
    if (!record) return json(401, { error: "no session" });
    return json(200, { code: record.principal.code, role: record.principal.role });
  }

  // Diagnostic endpoint: only for this lesson, to observe the store. Not present in a real service.
  if (path === "/diagnostics/store") {
    const { id } = resolveSession(request);
    return json(200, {
      recordsInStore: STORE.size,
      cookieIdPrefix: id ? id.slice(0, 12) : null,
      cookieIdInStore: id ? STORE.has(id) : null,
    });
  }
  json(404, { error: "no such resource" });
}).listen(PORT, "127.0.0.1", () => console.log(`session 127.0.0.1:${PORT} secure=${SECURE} fixed=${FIXED}`));

The session identifier is generated from 32 bytes of random data. This value’s unpredictability is not negotiable, because it is the record’s only protection: an identifier derived from a counter, a user code, or a timestamp opens the record itself to everyone.

The session identifier is carried by a cookie, and the cookie’s attributes determine where, when, and by whom the identifier will be sent. The same server’s two setups can be seen side by side in the response header.

node session.mjs 8521 > /dev/null & LOCAL=$!
node session.mjs 8522 secure > /dev/null & TLS=$!
sleep 1
echo "--- local setup (no TLS): Set-Cookie"
curl -s -D - -o /dev/null -X POST -H "content-type: application/json" \
  -d "{\"username\":\"clara.diaz\",\"password\":\"café-books-1876\"}" \
  http://127.0.0.1:8521/login | grep -i "^set-cookie"
echo "--- TLS setup: Set-Cookie"
curl -s -D - -o /dev/null -X POST -H "content-type: application/json" \
  -d "{\"username\":\"clara.diaz\",\"password\":\"café-books-1876\"}" \
  http://127.0.0.1:8522/login | grep -i "^set-cookie"
kill $LOCAL $TLS
--- local setup (no TLS): Set-Cookie
set-cookie: loan_session=jMg-ttx_fQin9m8R1-D4KebA_lvmsIwiDS8N1t5mObM; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800
--- TLS setup: Set-Cookie
set-cookie: __Host-loan_session=VQXNQ9l8dULDTTSpGvTJRPM-1t8Zj6em3_OCmZH_FWo; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800

The identifier values change on every run; what stays fixed is the attributes.

HttpOnly makes the cookie unreadable from a script. Its effect shows up in the browser: document.cookie does not see this cookie. The leak path shown in the Frontend Quality course’s Cross-Site Scripting lesson is closed off for the session identifier by this attribute. The only thing visible on the server side is that the attribute was sent; applying it is the client’s job.

Secure requires the cookie to be sent only over a TLS connection. This is the only meaningful difference between the two outputs above, and it is not optional for a setup behind TLS: a cookie without the attribute goes out on the network in the open for a client that falls back to an unencrypted request. The reason the local setup does not give the attribute is that the browser does not store a Secure cookie from an unencrypted origin in the first place; this is a separate reason for the development environment to run with TLS.

SameSite=Lax restricts the cookie from being attached to requests initiated from another site. The server operating on ambient authority — that is, the cookie being attached regardless of who initiated the request — is the source of cross-site request forgery; this attribute narrows the surface but is not a sufficient defense on its own.

Path=/ states which paths the cookie is sent to, and Max-Age states how long it is kept on the client. Under the host-prefix rule, the __Host- prefix requires the cookie to be Secure, carry Path=/, and specify no Domain; it prevents the cookie from being written by subdomains.

None of these attributes determine the lifetime of the record on the server. Max-Age states when the client will forget the cookie; the server decides when the record becomes invalid. When the two are confused, the result is a “client deleted it but it is still valid on the server” situation.

Renewing the Identifier at Login

The client may hold a session identifier before login. If the server retains this identifier at login, a value known before login starts pointing to an authorized record after login. This is called session fixation; how the value got placed on the client is a separate matter — the server’s job is not to inherit the value.

The measurement below compares the identifiers before and after login.

// fixation.mjs — measuring whether the session identifier is renewed at login
// Usage: while node session.mjs 8523 fixed &  and  node session.mjs 8524 &  are running
//        node fixation.mjs 8523   /   node fixation.mjs 8524
const PORT = Number(process.argv[2]);
const BASE = `http://127.0.0.1:${PORT}`;
const COOKIE_NAME = "loan_session";

// A value the client holds before login, determined from outside.
const PRESET = "preset-value-0001";

const login = async (cookieValue) => {
  const response = await fetch(`${BASE}/login`, {
    method: "POST",
    headers: { "content-type": "application/json", cookie: `${COOKIE_NAME}=${cookieValue}` },
    body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
  });
  const issued = (response.headers.get("set-cookie") ?? "").split(";")[0].split("=").slice(1).join("=");
  return { status: response.status, issued };
};

const whoAmI = async (cookieValue) => {
  const response = await fetch(`${BASE}/me`, { headers: { cookie: `${COOKIE_NAME}=${cookieValue}` } });
  return `${response.status} ${await response.text()}`;
};

const { issued } = await login(PRESET);
console.log("value before login      :", PRESET);
console.log("issued after login      :", issued.slice(0, 30) + (issued.length > 30 ? "…" : ""));
console.log("identifier renewed      :", issued !== PRESET);
console.log("/me with old value      :", await whoAmI(PRESET));
console.log("/me with new value      :", await whoAmI(issued));
node session.mjs 8523 fixed > /dev/null & FIXED=$!
node session.mjs 8524 > /dev/null & CORRECT=$!
sleep 1
echo "— identifier not renewed at login (wrong setup) —"
node fixation.mjs 8523
echo "— identifier renewed at login (correct setup) —"
node fixation.mjs 8524
kill $FIXED $CORRECT
— identifier not renewed at login (wrong setup) —
value before login      : preset-value-0001
issued after login      : preset-value-0001
identifier renewed      : false
/me with old value      : 200 {"code":"U-1001","role":"member"}
/me with new value      : 200 {"code":"U-1001","role":"member"}
— identifier renewed at login (correct setup) —
value before login      : preset-value-0001
issued after login      : 9edgXOW_qIMFrGSBQWaWbnIlI3uGmh…
identifier renewed      : true
/me with old value      : 401 {"error":"no session"}
/me with new value      : 200 {"code":"U-1001","role":"member"}

In the first setup, the value known before login returns member U-1001’s record after login. In the second setup, the same value gets a 401; the record has moved to a new, unpredictable identifier, and the old one has been deleted from the store. The rule is one sentence: at every point where the privilege level changes — login, account switch, second-factor verification, privilege escalation — the session identifier is renewed.

The Effect of Logout and Lifetime Limits

Because the session record lives on the server, deleting it produces a direct consequence. If there is no record, the next request gets a 401; there is no waiting period in between.

A record can also drop on its own. The idle timeout bounds the time elapsed since the last request; every request resets the counter. The absolute lifetime bounds the total time since the record was opened and is not extended by requests. In the measurement below, the idle timeout is pulled down to 1.5 seconds.

// lifetime.mjs — when logout and the idle timeout take effect
// Usage: while IDLE_MS=1500 node session.mjs 8525 &  is running,  node lifetime.mjs 8525
const PORT = Number(process.argv[2] ?? 8525);
const BASE = `http://127.0.0.1:${PORT}`;
const COOKIE_NAME = "loan_session";

const login = await fetch(`${BASE}/login`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
});
const cookie = (login.headers.get("set-cookie") ?? "").split(";")[0];
const headers = { cookie };
const status = async (path) => (await fetch(BASE + path, { headers })).status;
const store = async () => await (await fetch(`${BASE}/diagnostics/store`, { headers })).json();

console.log("after login /me         :", await status("/me"), "| store:", (await store()).recordsInStore);
await fetch(`${BASE}/logout`, { method: "POST", headers });
console.log("after logout /me        :", await status("/me"), "| store:", (await store()).recordsInStore);

// Second session: idle timeout
const login2 = await fetch(`${BASE}/login`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ username: "clara.diaz", password: "café-books-1876" }),
});
const headers2 = { cookie: (login2.headers.get("set-cookie") ?? "").split(";")[0] };
const status2 = async () => (await fetch(`${BASE}/me`, { headers: headers2 })).status;
console.log("new session /me         :", await status2());
await new Promise((c) => setTimeout(c, 800));
console.log("after 0.8s /me          :", await status2(), "(limit 1.5s; every request resets the counter)");
await new Promise((c) => setTimeout(c, 1800));
console.log("after 1.8s idle /me     :", await status2());
IDLE_MS=1500 node session.mjs 8525 > /dev/null & SERVER=$!
sleep 1
node lifetime.mjs 8525
kill $SERVER
after login /me         : 200 | store: 1
after logout /me        : 401 | store: 0
new session /me         : 200
after 0.8s /me          : 200 (limit 1.5s; every request resets the counter)
after 1.8s idle /me     : 401

After the logout request, no record was left in the store, and a request with the same cookie got a 401. This is the defining property of a session-based setup and the comparison point for the next lesson.

The Cost of the Store

Because the store lives on the server, it has a cost. Every request requires reading the store; in a single process this is a Map access, and across multiple processes it is a network call to a shared store. When the application scales horizontally, the record must live somewhere every process can reach; a store each process keeps in its own memory leaves a user without a session if their request lands on a different process. Solving this by always routing the request to the same process drops every session tied to that process when the process goes down.

In return, the store gives a measurable capability: how many open sessions an account has, when a session was last used, and which session can be closed with a single request are all information the server knows.

Summary

  • A session is a record opened on the server at login; the client carries only a random session identifier pointing to that record, and the password no longer goes out over the network.
  • Cookie attributes determine where and when the identifier is sent: HttpOnly restricts reading from a script, Secure restricts unencrypted transmission, SameSite restricts attachment to requests initiated from another site; the __Host- prefix makes these rules mandatory.
  • Max-Age states how long the client will keep the cookie; the server decides the record’s validity, and the two are separate lifetimes.
  • The session identifier is renewed at every point where the privilege level changes; in a setup that does not renew it, a value known before login points to an authorized record after login.
  • Because the record lives on the server, logout is effective immediately; the idle timeout is measured from the last request, and the absolute lifetime from when the record was opened.
  • The store’s cost is a read on every request and a shared-store requirement under horizontal scaling; in return, open sessions can be counted and closed individually.

Next Step

The store’s cost gives rise to the desire to eliminate it. Instead of turning an identifier into a record, the server can write the principal itself into the value the request carries, and sign that value to make sure it was not altered. In such a setup, verification comes down to a local computation, the shared-store requirement disappears, and services can recognize the same identity without connecting to each other’s session store. The next lesson builds this setup and measures its cost: if there is no record on the server, what does logout delete, and how long does a value that is never deleted stay valid?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close