---
title: 'Session Management Errors'
source: 'https://academia.sh/en/courses/secure-coding/session-management-errors'
course: 'Secure Coding'
language: en
updated: '2026-09-26T03:02:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts the flows that generate a session identifier and flags the ones that do not renew it as fixation points'
    - 'Measures the number of sessions that continue to live on when there is no server-side invalidation'
    - 'Compares the flow coverage of distributed session decisions against a central pattern'
---

# Session Management Errors

Counting session decisions over a flow set: four of seven flows raise the session's authorization level, and two of those are fixation points because they do not renew the identifier; two of three invalidation flows delete no record server-side, so eight sessions continue to live on; a central session pattern brings this number down to two; when an eighth flow is added, the number of flows left uncovered in the distributed setup rises to five; and it is measured that the transfer flow, which never calls the pattern at all, is covered only by a pattern tied to the authorization version.

The previous lesson measured the moment identity is established and counted that five of eight
points leak user existence. Once authentication succeeds, a new object appears: the session
identifier generated so identity does not have to be asked for again on subsequent requests.
This identifier is generated once and afterward is changed, upgraded, or released by many
flows.

What a session is and how it is stored was established in the Authentication and Authorization
course. What is measured here is **where the decisions are made**. Every session-related
decision is made inside a flow; as the number of flows grows, the number of decisions grows,
and when each decision is written separately, some of them are written wrong. This lesson's
question is: how many flows touch the session identifier, how many touch it correctly, and how
many are left uncovered.

## Two Rules, Seven Flows

The flows that touch the session identifier fall into two types. An **upgrade flow** raises the
session's authorization level: an anonymous session becomes logged in, a logged-in session
completes its second factor, a staff session gains administrator rights. The rule for this type
is single — when the level changes, the session identifier **is renewed**. When it is not
renewed, an identifier known before the level changed stays valid after the level changes too;
this is called **session fixation**.

An **invalidation flow**, in turn, takes authorization back: logout, a password change,
transferring a subscription to another person. The rule for this type is server-side — what has
to be deleted is the record in the ledger. Deleting the cookie on the client side does nothing
to the ledger; a client that holds on to the identifier can keep sending it.

The flow set and session ledger below are a **model**: there is no real session store, no real
cookie, and no real subscriber.

```js
// session/model.mjs — the subscriber portal's session flows and server-side session ledger
// (model): no real session store, no real cookie and no real subscriber.

// IE5: if a flow raises the session's authorization level, the session identifier must be
// renewed (expected: "renew"); if it takes authorization back, the affected sessions must be
// deleted server-side (expected: "single" for logout, "all" for the others). The `now` field
// is today's behavior.
export const FLOW = [
  { name: "portal-login",           type: "upgrade",    now: "renew" },
  { name: "second-factor-approval", type: "upgrade",    now: "in-place" },
  { name: "role-elevation",         type: "upgrade",    now: "in-place" },
  { name: "field-app-pairing",      type: "upgrade",    now: "renew" },
  { name: "logout",                 type: "invalidate", now: "single" },
  { name: "password-change",        type: "invalidate", now: "none" },
  { name: "subscription-transfer",  type: "invalidate", now: "none" },
];
export const expected = (a) =>
  a.type === "upgrade" ? "renew" : a.name === "logout" ? "single" : "all";

// IE6: the session ledger is server-side; an identifier resolves only while its record exists
// in the ledger. Deleting the cookie on the client side does not touch the ledger. When
// `versioned` is on, the record carries the subscriber's authorization version and becomes
// invalid when the version changes, without being deleted.
export function openLedger({ versioned = false } = {}) {
  const records = new Map(), version = new Map();
  let counter = 0;
  const versionOf = (subscriber) => version.get(subscriber) ?? 1;
  return {
    open(subscriber, level) {
      const k = `s-${++counter}`;
      records.set(k, { subscriber, level, version: versionOf(subscriber) });
      return k;
    },
    resolve(k) {
      const v = records.get(k);
      if (!v) return null;
      return versioned && v.version !== versionOf(v.subscriber) ? null : v;
    },
    updateInPlace(k, level) { const v = records.get(k); if (v) v.level = level; },
    remove: (k) => records.delete(k),
    removeAll(subscriber) {
      let n = 0;
      for (const [k, v] of records) if (v.subscriber === subscriber) { records.delete(k); n++; }
      return n;
    },
    bumpVersion(subscriber) { version.set(subscriber, versionOf(subscriber) + 1); },
    live(subscriber) {
      return [...records.keys()].filter((k) => this.resolve(k)?.subscriber === subscriber).length;
    },
  };
}
```

```js
// session/measurement.mjs — how many flows generate a session identifier, how many fail to
// renew it, and how many sessions continue to live on when there is no invalidation.
import { FLOW, expected, openLedger } from "./model.mjs";
const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));

// IE7: a fixation point is a flow where, after the upgrade, the old identifier still resolves
// to the upgraded level. The loop below actually runs this on the ledger.
const upgrades = FLOW.filter((a) => a.type === "upgrade");
const A = [-24, 10, 24, 20];
console.log(`1. ${upgrades.length} upgrade flows: what the old identifier resolves to after login`);
write(A, "flow", "now", "old identifier's level", "fixation point");
let fixation = 0;
for (const a of upgrades) {
  const d = openLedger();
  const old = d.open("a-4471", "anonymous");
  if (a.now === "renew") { d.remove(old); d.open("a-4471", "logged-in"); }
  else d.updateInPlace(old, "logged-in");
  const level = d.resolve(old)?.level ?? "not-resolved";
  if (level === "logged-in") fixation++;
  write(A, a.name, a.now, level, level === "logged-in" ? "YES" : "no");
}
console.log(`session-identifier-generating flow: ${upgrades.length}, not renewing: ${fixation}`);

// IE8: every subscriber has three live sessions opened from three separate endpoints; what is
// measured is the number of sessions remaining in the ledger after an invalidation flow.
const apply = (policy, d, k) =>
  policy === "single" ? d.remove(k) : policy === "all" ? d.removeAll("a-4471") : 0;
const living = (policy) => {
  const d = openLedger();
  const identifiers = [d.open("a-4471", "logged-in"), d.open("a-4471", "logged-in"), d.open("a-4471", "logged-in")];
  apply(policy, d, identifiers[0]);
  return d.live("a-4471");
};
const invalidations = FLOW.filter((a) => a.type === "invalidate");
const B = [-22, 12, 10, 12, 10];
console.log("\n2. invalidation flows (start: 3 live sessions per flow)");
write(B, "flow", "now", "living", "expected", "living");
let nowTotal = 0, patternTotal = 0;
for (const a of invalidations) {
  const s = living(a.now), b = living(expected(a));
  nowTotal += s; patternTotal += b;
  write(B, a.name, a.now, s, expected(a), b);
}
write(B, "total", "", nowTotal, "", patternTotal);

// 3. coverage: how many flows already do what the pattern expects
const covered = (set) => set.filter((a) => a.now === expected(a)).length;
const NEW_FLOW = { name: "field-device-swap", type: "upgrade", now: "in-place" };
const extended = [...FLOW, NEW_FLOW];
const C = [-34, 6, 10, 10, 12];
console.log("\n3. flow set coverage");
write(C, "set", "flows", "covered", "uncovered", "uncovered %");
for (const [name, k, central] of [
  ["today's distributed state", FLOW, false],
  ["central session pattern", FLOW, true],
  ["new flow added, distributed", extended, false],
  ["new flow added, central pattern", extended, true],
]) {
  const cov = central ? k.length : covered(k);
  write(C, name, k.length, cov, k.length - cov, (((k.length - cov) / k.length) * 100).toFixed(1));
}

// 4. the flow the pattern cannot reach: `subscription-transfer` changes the subscription
// record from outside the portal and may not call the session pattern. A version-bound
// pattern needs no call.
const transfer = (versioned, notifies) => {
  const d = openLedger({ versioned });
  d.open("a-4471", "logged-in"); d.open("a-4471", "logged-in"); d.open("a-4471", "logged-in");
  if (notifies) d.removeAll("a-4471");
  d.bumpVersion("a-4471");                      // the subscription record changes in every case
  return d.live("a-4471");
};
const D = [-24, 16, 16, 16];
console.log("\n4. subscription-transfer flow (3 live sessions): what happens if the pattern is not called");
write(D, "setup", "notified event", "living session", "covered flow");
for (const [name, versioned, notifies] of [
  ["call-bound pattern", false, true],
  ["call-bound pattern", false, false],
  ["version-bound pattern", true, false],
]) write(D, name, notifies ? "yes" : "no", transfer(versioned, notifies),
  `${versioned || notifies ? invalidations.length : invalidations.length - 1} / ${invalidations.length}`);
```

```
1. 4 upgrade flows: what the old identifier resolves to after login
flow                           now  old identifier's level      fixation point
portal-login                 renew            not-resolved                  no
second-factor-approval    in-place               logged-in                 YES
role-elevation            in-place               logged-in                 YES
field-app-pairing            renew            not-resolved                  no
session-identifier-generating flow: 4, not renewing: 2

2. invalidation flows (start: 3 live sessions per flow)
flow                           now    living    expected    living
logout                      single         2      single         2
password-change               none         3         all         0
subscription-transfer         none         3         all         0
total                                      8                     2

3. flow set coverage
set                                flows   covered uncovered uncovered %
today's distributed state              7         3         4        57.1
central session pattern                7         7         0         0.0
new flow added, distributed            8         3         5        62.5
new flow added, central pattern        8         8         0         0.0

4. subscription-transfer flow (3 live sessions): what happens if the pattern is not called
setup                     notified event  living session    covered flow
call-bound pattern                   yes               0           3 / 3
call-bound pattern                    no               3           2 / 3
version-bound pattern                 no               0           3 / 3
```

The numbers belong to the **measurement** class; their inputs are IE5–IE8.

## Four Flows Generate, Two Do not Renew

The first table counts the upgrade side. Four flows change the session's authorization level;
two renew the identifier, two upgrade the record **in place**. In the two flows that upgrade in
place, the old identifier continues to resolve to the `logged-in` level; in the two that renew,
it is `not-resolved`.

Which two are the fixation points is instructive. `portal-login` is written correctly — this is
the flow everyone looks at, and the rule is best known there. The two written wrong are
`second-factor-approval` and `role-elevation`: both are flows that come **after** login and are
not mistaken for login itself. When the rule is memorized as "the identifier is renewed at
login," flows that raise the level after login fall outside it.

This is also where the number's meaning lies. The sentence "we are protected against session
fixation" is true about one flow and half-true about four flows. Its measurable form is this:
**session-identifier-generating flow: 4, not renewing: 2.**

It is also easy to see why in-place upgrading was written this way. Updating the record in
place does less work: no new identifier is generated, no new value is sent to the client,
requests in flight are not affected. Renewing, on the other hand, requires copying the record,
deleting the old one, and having the client receive the new value. Both fixation points having
chosen this cheap path is not carelessness — it is the rule never having been asked at that flow
at all.

## Eight Sessions Continue to Live On

The second table counts the invalidation side. Every flow runs on a subscriber who has three
live sessions; what is measured is the number of records left in the ledger after the flow.

The `logout` flow deletes its own session, and two sessions remain. This is correct: logging
out from one endpoint does not close sessions on the other endpoints. `password-change` and
`subscription-transfer`, however, delete no record in the ledger at all; all three continue to
live on. A total of eight sessions are still alive after the three flows where authorization is
supposed to have been taken back.

Why the two flows are in this state has to do with where the work is done. The password-change
flow's visible job is updating the credential; touching the session ledger does not look like
part of that job. Subscription transfer, in turn, is a flow that belongs to the party that
manages the subscription, not the portal, and it may not even know the session ledger exists.
In both cases, what is missing is not a check — it is **a call**.

How the number is measured also matters. Every flow runs on a subscriber who has three live
sessions; the three sessions are the same person's separate endpoints. As the number of
sessions per subscriber grows, the number of sessions a non-invalidating flow leaves behind
grows directly with it. This is why the unit of the measure is not "flow" but **session living
on after the flow**: two missing flows are an item that grows as it is multiplied by the
session count.

The expected column runs the same flows with the correct policy: `single` for `logout`, `all`
for the other two. The total drops from eight to **two**, and the two remaining sessions are
the sessions at the logout flow's other endpoints — sessions that are not supposed to be
closed. The measurement shows one more thing here: the correct number is not zero. If "close
all" were correct for every flow, the user would also be dropped from the other endpoints when
logging out from one.

## The Flow the Pattern Covers

The third table counts coverage. **Three** of the seven flows do what the pattern expects; four
are left uncovered — the uncovered rate is 57.1%.

The central session pattern is a single function and keeps two things in one place: which event
corresponds to which policy, and how that policy is applied to the ledger. Flows do not make
the decision themselves; they report the event. In this setup the number of covered flows is 7
and the uncovered count is zero — but this zero does not come from the pattern being clever, it
comes from the decision having been made **once**.

The last two rows answer the new-flow question. The eighth flow — `field-device-swap`, a field
technician replacing their terminal — raises the distributed setup's uncovered count from 4 to
5 and the rate climbs to 62.5%. In the central pattern, the same flow reports an event and the
uncovered count stays at zero. In a distributed setup, every new flow requires remembering both
rules all over again; in the pattern, only writing the event's name correctly.

## The Flow That Does Not Call the Pattern

The fourth table exposes the assumption the third table's zero rests on. The central pattern is
a **call-bound** pattern: it runs when a flow reports the event, and does not run when it does
not. This assumption holds for six of the seven flows without issue, because six of them are
the portal's own code.

`subscription-transfer` is different. The party that changes the subscription record is not the
portal; the transfer is a separate flow that updates the subscription record directly. The
table separates two cases: when the flow reports the event, the three sessions drop to zero;
when it does not, **all three continue to live on** and the covered-flow count drops from 3 to
2. The central pattern stays right where it is, written correctly, and working; the only thing
it does not cover is the flow that does not call it.

The last row changes the pattern's shape. If the session record carries the subscriber's
**authorization version**, resolution compares the version in the record against the
subscriber's current version on every request. When the transfer flow changes the subscription
record, the version increases regardless; even though it never calls the session pattern, all
three sessions become invalid — living session: 0, covered flow: 3/3. The difference is where
the defense is attached: a call-bound pattern waits for the flow to remember; a data-bound
pattern uses a change the flow was already making as its criterion. The latter's coverage stays
constant as the flow set grows.

## Summary

- Session decisions reduce to two rules: a flow that raises the authorization level renews the
  identifier, and a flow that takes authorization back deletes the record server-side. Both are
  decided separately per flow.
- Four of seven flows generate a session identifier; two are fixation points because they do
  not renew the identifier, and the two written wrong are flows that come after login.
- Two of three invalidation flows delete no record in the ledger at all; eight sessions
  continue to live on after all three, and the correct policy brings this number down to two.
- The correct number is not zero: the two sessions at the other endpoints living on after the
  logout flow is the expected outcome; the policy is set per flow.
- Three of seven flows do what the pattern expects, four are left uncovered (57.1%); when an
  eighth flow is added, the uncovered count stays at zero in the central pattern while it rises
  to five in the distributed setup.
- A call-bound pattern does not cover a flow that does not call it: when the transfer flow does
  not report the event, all three sessions live on. When the session record carries the
  subscriber's authorization version, the same flow invalidates all three sessions without
  making any call at all.

## Next Step

These two lessons secured the identity behind the request: who it is was established without
leaking, and the carrier of that identity was renewed at the right moments and deleted at the
right moments. Knowing the identity correctly, however, is only half the decision. When a known
identity sends a request to an endpoint, there is a second question that has to be asked, and
it is asked per endpoint: can this identity touch **this object**? The next lesson derives the
authorization matrix and gives this topic's most important number: how many endpoints perform
only a role check, how many also check the ownership of the requested object, and whether the
central pattern covers a newly added endpoint.
