Skip to content
academia.sh

Lesson 16 / 23

Session Security

Renewing the session identifier at login, the observable result of session fixation, listing and individually terminating concurrent sessions, enforcing idle and absolute lifetime limits together, and the role of cookie attributes.

Contents

The previous lesson’s last rule was that a successful reset closes every session tied to the account. In practice, that means a session is a record kept on the server side: what can be closed is what has a record. This lesson covers a session’s life from opening to termination.

The Session-Based Authentication lesson established what a session is: the server keeps a record, hands its identifier to the browser as a cookie, and recognizes the user on later requests by that identifier. The questions here are when it changes, how long it lives, and who can close it.

Session Fixation and Renewal at Login

A user can hold a session before signing in — a language preference, a cart, search history sit in an anonymous session. What happens to that session at login is a decision between two options: the same identifier gets treated as signed in, or it gets renewed.

The first option is open to a problem called session fixation: someone who knows the user’s pre-login identifier keeps holding it the moment the user signs in, and now holds the identifier of a signed-in session. How it was learned is a separate question; the flaw is that a learned identifier stays valid after login.

The block below runs two setups side by side on a local server.

cat > session-server.mjs <<'EOF'
import { createServer } from "node:http";
import { randomBytes } from "node:crypto";

const sessions = new Map();                  // id -> { member }

const readId = (request) =>
  /(?:^|;\s*)session=([^;]+)/.exec(request.headers.cookie ?? "")?.[1];

function newSession(member = null) {
  const id = randomBytes(24).toString("base64url");
  sessions.set(id, { member });
  return id;
}

const cookie = (id) => `session=${id}; Path=/; HttpOnly; SameSite=Lax`;

createServer((request, response) => {
  const path = new URL(request.url, "http://local").pathname;
  const id = readId(request);

  if (path === "/health") return response.writeHead(200).end("ready\n");

  if (path === "/") {                          // anonymous visit: opens a session
    if (id && sessions.has(id)) return response.writeHead(200).end(id + "\n");
    const fresh = newSession();
    return response.writeHead(200, { "set-cookie": cookie(fresh) }).end(fresh + "\n");
  }

  if (path === "/flawed/login") {              // the setup that should be rejected
    const s = sessions.get(id);
    if (!s) return response.writeHead(400).end("no session\n");
    s.member = "U-1001";                          // same id, now signed in
    return response.writeHead(200).end(id + "\n");
  }

  if (path === "/login") {                      // correct setup
    if (id) sessions.delete(id);      // the old id is invalidated
    const fresh = newSession("U-1001");
    return response.writeHead(200, { "set-cookie": cookie(fresh) }).end(fresh + "\n");
  }

  if (path === "/me") {
    const s = sessions.get(id);
    return response.writeHead(s ? 200 : 401).end((s ? (s.member ?? "anonymous") : "invalid") + "\n");
  }

  response.writeHead(404).end("");
}).listen(8495, "127.0.0.1");
EOF
node session-server.mjs & server=$!
until curl -sf http://127.0.0.1:8495/health > /dev/null; do :; done

probe() {
  jar=$(mktemp); route=$1
  before=$(curl -s -c "$jar" -b "$jar" http://127.0.0.1:8495/)
  after=$(curl -s -c "$jar" -b "$jar" "http://127.0.0.1:8495$route")
  echo "  id before login: ${before:0:14}..."
  echo "  id after login : ${after:0:14}..."
  echo "  id changed     : $([ "$before" = "$after" ] && echo no || echo yes)"
  echo "  /me with old id: $(curl -s -H "cookie: session=$before" http://127.0.0.1:8495/me)"
  rm -f "$jar"
}

echo "--- the setup that should be rejected (/flawed/login) ---"; probe /flawed/login
echo "--- correct setup (/login) ---";                            probe /login

kill $server
rm -f session-server.mjs
--- the setup that should be rejected (/flawed/login) ---
  id before login: gRGPZYwR_quHEa...
  id after login : gRGPZYwR_quHEa...
  id changed     : no
  /me with old id: U-1001
--- correct setup (/login) ---
  id before login: T6XXF7v8d6luLL...
  id after login : HJTvwh-Ak1s590...
  id changed     : yes
  /me with old id: invalid

Identifier values change on every run; the difference between the two setups does not. In the setup that should be rejected, an identifier captured before login answers as U-1001 afterward. In the correct setup the same identifier comes back invalid, because the old record is deleted and a new one opened at the moment of login.

The rule fits in one sentence: the session identifier is renewed on every operation that changes the privilege level. Login is the clearest example; a password change, completing a second factor, and role elevation fall under it too. Data that needs to carry over from the anonymous session — a language preference, say — gets copied to the new record during renewal. What does not carry over is the identifier itself.

Concurrent Sessions

A member can be signed in from more than one place at once — the desktop at the branch, their phone, the hall terminal. Each is a separate session record, each terminable on its own. The “active sessions” list is the only place the user can see whether their account has an unexpected access.

The record holds not the identifier itself but its hash — the same reasoning as the reset token in the previous lesson: anyone who can read the database should not be able to use an active session.

cat > session-store.mjs <<'EOF'
import { randomBytes, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE session(
  hash TEXT PRIMARY KEY, member TEXT NOT NULL, device TEXT NOT NULL,
  opened INTEGER NOT NULL, last_seen INTEGER NOT NULL)`);

const hash = (k) => createHash("sha256").update(k).digest("hex");
const IDLE_MS = 30 * 60 * 1000;              // idle limit: 30 minutes
const ABSOLUTE_MS = 12 * 60 * 60 * 1000;     // absolute lifetime: 12 hours
const T0 = Date.parse("2026-03-10T09:00:00Z");   // this example's "now"

function open(member, device, openedAgo, seenAgo) {
  const id = randomBytes(24).toString("base64url");
  db.prepare("INSERT INTO session VALUES (?,?,?,?,?)")
    .run(hash(id), member, device, T0 - openedAgo, T0 - seenAgo);
  return id;
}

const min = 60 * 1000, hr = 60 * min;
open("U-1001", "desktop-branch-1", 2 * hr, 3 * min);
const phone = open("U-1001", "phone", 1 * hr, 45 * min);
const old = open("U-1001", "hall-terminal", 14 * hr, 5 * min);
open("U-1002", "phone", 30 * min, 1 * min);

const state = `CASE
  WHEN ? - last_seen > ${IDLE_MS} THEN 'idle limit reached'
  WHEN ? - opened    > ${ABSOLUTE_MS} THEN 'absolute lifetime reached'
  ELSE 'valid' END`;

const list = (member) => db.prepare(
  `SELECT device, (? - opened)/60000 AS opened_min, (? - last_seen)/60000 AS idle_min,
          ${state} AS state
   FROM session WHERE member = ? ORDER BY opened`).all(T0, T0, T0, T0, member);

const print = (title, member) => {
  console.log(title);
  for (const s of list(member)) {
    console.log(`  ${s.device.padEnd(16)} opened=${String(s.opened_min).padStart(3)} min  ` +
      `idle=${String(s.idle_min).padStart(2)} min  ${s.state}`);
  }
};

print("U-1001 sessions:", "U-1001");

const removed = db.prepare("DELETE FROM session WHERE hash = ? AND member = ?")
  .run(hash(old), "U-1001").changes;
console.log(`\none session terminated (rows affected: ${removed})`);
print("U-1001 sessions:", "U-1001");

const all = db.prepare("DELETE FROM session WHERE member = ?").run("U-1001").changes;
console.log(`\nall sessions closed after reset (rows affected: ${all})`);
console.log("U-1001 remaining sessions:",
  db.prepare("SELECT COUNT(*) c FROM session WHERE member='U-1001'").get().c);
console.log("U-1002 remaining sessions:",
  db.prepare("SELECT COUNT(*) c FROM session WHERE member='U-1002'").get().c);
console.log("is the phone id still valid:",
  db.prepare("SELECT COUNT(*) c FROM session WHERE hash = ?").get(hash(phone)).c > 0);
EOF
node session-store.mjs
U-1001 sessions:
  hall-terminal    opened=840 min  idle= 5 min  absolute lifetime reached
  desktop-branch-1 opened=120 min  idle= 3 min  valid
  phone            opened= 60 min  idle=45 min  idle limit reached

one session terminated (rows affected: 1)
U-1001 sessions:
  desktop-branch-1 opened=120 min  idle= 3 min  valid
  phone            opened= 60 min  idle=45 min  idle limit reached

all sessions closed after reset (rows affected: 2)
U-1001 remaining sessions: 0
U-1002 remaining sessions: 1
is the phone id still valid: false

The AND member = ? condition in the delete statement previews the entire next topic: a user should only be able to close their own session. Without it, anyone who knows an identifier could close any session — this lesson’s first glimpse of object-level access control.

The last three lines prove the reset rule: every one of U-1001’s sessions closed, U-1002’s was untouched, and the phone identifier held earlier is no longer in the record.

Two Lifetime Limits

The output’s status column evaluates two separate limits together.

The idle limit measures time since the last access, keeping a session from staying open on a device the user is not sitting at. The phone session hit it — unused for forty-five minutes.

The absolute lifetime measures time since the session opened, regardless of use, keeping a continuously used session from living forever. The hall-terminal session hit it — open for fourteen hours despite being used five minutes ago.

The two coexist because they solve separate problems: with only an idle limit, a hijacked session can be refreshed forever with regular requests; with only an absolute lifetime, a session on an abandoned device stays open until the limit hits.

The values chosen depend on the account’s privilege — a short idle limit for a staff session at the loan desk, a longer one for a member session; the measure is how wide a door the session opens.

Enforcing the limits happens in a query; a separate cleanup job deletes expired records. Neither substitutes for the other — a lagging cleanup job still leaves the query deciding correctly, and a late query still gets its record removed by the cleanup job.

Carrying the Identifier

The session identifier travels between browser and server in a cookie, and the cookie’s attributes protect it. The server above sets three of them.

HttpOnly keeps the cookie from being read by scripts on the page: when cross-site scripting, covered in the Client-Side Security topic, happens on a page, this attribute stops the identifier from being read directly.

SameSite limits the cookie from being attached to requests started by other sites, and it is the first defense against cross-site request forgery.

Secure is absent from the example above because the local server speaks plain HTTP. In a real deployment it is mandatory — the cookie goes out only over secure transport. Without transport security the other two attributes are worth little; the identifier travels the network in the open.

These three attributes make it harder, not impossible, for the identifier to fall into someone else’s hands — an event called session hijacking. A second layer sits on the server side for this reason: the session record holds device and source information, and an unexpected change terminates the session and asks for authentication again. The strictness of this check is a trade-off; a rule that keeps kicking out a user who switches networks pushes them toward never closing the session at all.

Summary

  • The session identifier is renewed on every operation that changes the privilege level; in a setup that skips this, an identifier known before login stays valid after it.
  • Sessions are kept as records on the server side, with the identifier’s hash stored; what can be closed is what has a record.
  • An account’s concurrent sessions can be listed, closed individually, and terminated in bulk during a reset.
  • The idle limit and the absolute lifetime solve separate problems and are enforced together.
  • Cookie attributes protect the identifier in transit; the server-side device check is a second layer, and its strictness is balanced against user behavior.

Next Step

One question ran through this entire topic: how does the server reliably know who it is talking to? The AND member = ? condition in the session-delete query pointed at a different one — what someone whose identity is known is allowed to do. The next topic takes up this second question, starting with role-based access control: binding permissions to a role rather than a person, how role inheritance hides the effective permission set, and how library staff’s permissions get derived from a chart.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close