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

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

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.

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

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

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

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