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

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

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.

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

## Cookie Attributes

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.

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

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

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

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

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