CYART // SCHEMA · SSO-IDP7 NODES · 7 LINKSOPEN APP1AUTHN REQUEST2LOOKUPSIGN TOKENID TOKEN / SAML3ACTOR · UserACTOR01UserSERVICE · App (SP / RP) — redirects, verifiesSERVICE02App (SP / RP)redirects, verifiesSERVICE · IdP core — sessions, MFA, policiesSERVICE03IdP coresessions, MFA, policiesDATA · Directory — users, groups, SCIMDATA04Directoryusers, groups, SCIMDATA · Signing keys — HSM, kid rotationDATA05Signing keysHSM, kid rotationCONTROL · Risk signals — new device, impossible travelCONTROL06Risk signalsnew device, impossible travelDATA · Audit — every login eventDATA07Auditevery login event

Design a session manager

Design the server-side session component behind a web app: opaque random ids stored only as hashes, rotation on login and privilege change, idle and absolute timeouts, revocation that a concurrent request cannot undo, light device binding and a store interface with compare-and-set.

● MediumFREEDesign problem·45 min read·Intermediate → Advanced
sessionsfixationrevocationconcurrencyTypeScript

01Understand the problem

✓What you will learn
  • Issue opaque, high-entropy ids and store only their hash
  • Rotate on login and privilege change to kill fixation
  • Enforce idle and absolute timeouts that activity cannot stretch
  • Make revocation stick under concurrency with compare-and-set
◎Interview radar

What they probe: id entropy and storage, what happens to the pre-login id, whether rotation extends the lifetime, how logout everywhere works, and what a racing request does to a revoked session. Where candidates slip: storing raw ids, reusing the anonymous id after login, sliding the absolute timeout, a blind write on every request that resurrects revoked sessions.

  1. login(userId) issues a new id; any pre-login id is revoked.
  2. validate(id) returns the session or a typed reason; it refreshes last-seen at most once a minute.
  3. Idle timeout 30 minutes; absolute 12 hours from login; neither is extended by rotation.
  4. rotate(id, { elevate }) issues a new id after a privilege change; the old id keeps working for a few seconds for in-flight requests, never with elevated rights.
  5. Logout revokes one session; password change revokes all of a user's sessions.

02Interfaces first

The contractts
export interface DeviceCtx { userAgent: string }

export interface Session {
  idHash: string;               // SHA-256 of the opaque id; the raw id exists only in the cookie
  userId: string;
  createdAt: number;            // ms since epoch
  lastSeenAt: number;
  absoluteExpiresAt: number;    // fixed at login; carried, never extended
  elevatedUntil?: number;       // step-up auth window
  device: { uaHash: string };
  rotated: boolean;             // a successor exists; this one is in its grace window
  revokedAt?: number;           // effective from this instant (future = grace)
  version: number;              // for compare-and-set
}

export type DenyReason = "malformed" | "unknown" | "revoked" | "idle_timeout" | "absolute_timeout" | "device_mismatch";
export type Validation = { ok: true; session: Session; elevated: boolean } | { ok: false; reason: DenyReason };

export interface SessionStore {
  get(idHash: string): Promise<Session | null>;
  /** Fails if idHash already exists. Should set a store TTL at absoluteExpiresAt. */
  create(s: Session): Promise<void>;
  /** Compare-and-set: writes only if the stored version equals expectedVersion. */
  replace(idHash: string, expectedVersion: number, next: Session): Promise<boolean>;
  /** Revokes every session of the user not already revoked by at. Returns how many. */
  revokeAllForUser(userId: string, at: number): Promise<number>;
}

export interface SessionConfig { idleMs: number; absoluteMs: number; elevatedMs: number; rotationGraceMs: number; touchEveryMs: number }

export class SessionError extends Error {
  constructor(readonly reason: DenyReason | "already_rotated" | "contention") { super(reason); this.name = "SessionError"; }
}
iWhy hash the id

A read of the session table (backup, replica, SQL injection) should not hand out live sessions. With 256-bit random ids a plain SHA-256 is enough: there is nothing to brute force, and lookup by hash means no secret is ever compared, so timing is a non-issue.

03The manager

FigWhere sessions sit
CYART // SCHEMA · SSO-IDP7 NODES · 7 LINKSOPEN APP1AUTHN REQUEST2LOOKUPSIGN TOKENID TOKEN / SAML3ACTOR · UserACTOR01UserSERVICE · App (SP / RP) — redirects, verifiesSERVICE02App (SP / RP)redirects, verifiesSERVICE · IdP core — sessions, MFA, policiesSERVICE03IdP coresessions, MFA, policiesDATA · Directory — users, groups, SCIMDATA04Directoryusers, groups, SCIMDATA · Signing keys — HSM, kid rotationDATA05Signing keysHSM, kid rotationCONTROL · Risk signals — new device, impossible travelCONTROL06Risk signalsnew device, impossible travelDATA · Audit — every login eventDATA07Auditevery login event
  • ACTOR
  • SERVICE
  • DATA
  • CONTROL
  • Live flow
  • Lookup / async
//After the IdP authenticates the user, the application issues its own opaque session id in a cookie; every request validates it against the store, and privilege changes rotate it.
Session managerts
import { createHash, randomBytes } from "node:crypto";

const ID_RE = /^[A-Za-z0-9_-]{43}$/;                        // 32 random bytes, base64url
const sha256 = (s: string) => createHash("sha256").update(s).digest("base64url");
const deviceOf = (ctx: DeviceCtx) => ({ uaHash: sha256(ctx.userAgent) });
type UpdateResult = "written" | "missing" | "skipped";

export class SessionManager {
  constructor(private readonly store: SessionStore, private readonly cfg: SessionConfig, private readonly clock: () => number = Date.now) {
    const { idleMs, absoluteMs, elevatedMs, rotationGraceMs, touchEveryMs } = cfg;
    if (![idleMs, absoluteMs, elevatedMs, rotationGraceMs, touchEveryMs].every((n) => Number.isFinite(n) && n > 0)) throw new Error("timeouts must be positive");
    if (idleMs >= absoluteMs) throw new Error("idle timeout must be shorter than the absolute timeout");
    if (touchEveryMs >= idleMs) throw new Error("touchEveryMs must be shorter than idleMs");
    if (rotationGraceMs > 60_000) throw new Error("rotation grace covers in-flight requests: keep it under a minute");
  }

  async login(userId: string, ctx: DeviceCtx, preLoginId?: string): Promise<{ id: string }> {
    const now = this.clock();
    if (preLoginId !== undefined && ID_RE.test(preLoginId)) {
      await this.update(sha256(preLoginId), (s) => ({ ...s, revokedAt: now }));   // fixation: a pre-auth id is never upgraded
    }
    return this.mint({ userId, createdAt: now, absoluteExpiresAt: now + this.cfg.absoluteMs, device: deviceOf(ctx) }, now);
  }

  async validate(rawId: string, ctx: DeviceCtx): Promise<Validation> {
    if (!ID_RE.test(rawId)) return { ok: false, reason: "malformed" };
    const s = await this.store.get(sha256(rawId));
    if (!s) return { ok: false, reason: "unknown" };
    const now = this.clock();
    if (s.revokedAt !== undefined && now >= s.revokedAt) return { ok: false, reason: "revoked" };
    if (now >= s.absoluteExpiresAt) return { ok: false, reason: "absolute_timeout" };
    if (now - s.lastSeenAt >= this.cfg.idleMs) return { ok: false, reason: "idle_timeout" };
    if (s.device.uaHash !== deviceOf(ctx).uaHash) {
      await this.update(s.idHash, (cur) => ({ ...cur, revokedAt: now }));      // likely a stolen cookie: kill it
      return { ok: false, reason: "device_mismatch" };
    }
    if (now - s.lastSeenAt >= this.cfg.touchEveryMs) {
      // CAS, not a blind put: if a revoke landed since our read, this write loses instead of resurrecting the session
      await this.store.replace(s.idHash, s.version, { ...s, lastSeenAt: now, version: s.version + 1 });
    }
    return { ok: true, session: s, elevated: (s.elevatedUntil ?? 0) > now };
  }

  async rotate(rawId: string, ctx: DeviceCtx, opts: { elevate: boolean }): Promise<{ id: string }> {
    const v = await this.validate(rawId, ctx);
    if (!v.ok) throw new SessionError(v.reason);
    const now = this.clock();
    const old = v.session;
    const res = await this.update(old.idHash, (s) =>
      s.rotated ? null : { ...s, rotated: true, elevatedUntil: undefined, revokedAt: Math.min(s.revokedAt ?? Infinity, now + this.cfg.rotationGraceMs) });
    if (res === "skipped") throw new SessionError("already_rotated");
    if (res === "missing") throw new SessionError("unknown");
    return this.mint({
      userId: old.userId, createdAt: old.createdAt, device: old.device,
      absoluteExpiresAt: old.absoluteExpiresAt,                                    // rotation never extends the lifetime
      elevatedUntil: opts.elevate ? Math.min(now + this.cfg.elevatedMs, old.absoluteExpiresAt) : old.elevatedUntil,
    }, now);
  }

  async logout(rawId: string): Promise<void> {
    if (ID_RE.test(rawId)) await this.update(sha256(rawId), (s) => ({ ...s, revokedAt: this.clock() }));
  }

  revokeAll(userId: string): Promise<number> { return this.store.revokeAllForUser(userId, this.clock()); }

  private async mint(base: Pick<Session, "userId" | "createdAt" | "absoluteExpiresAt" | "device" | "elevatedUntil">, now: number): Promise<{ id: string }> {
    const id = randomBytes(32).toString("base64url");
    await this.store.create({ ...base, idHash: sha256(id), lastSeenAt: now, rotated: false, version: 0 });
    return { id };
  }

  /** Read-modify-CAS with a bounded retry. f returns null to skip the write. */
  private async update(idHash: string, f: (s: Session) => Session | null): Promise<UpdateResult> {
    for (let attempt = 0; attempt < 3; attempt++) {
      const cur = await this.store.get(idHash);
      if (!cur) return "missing";
      const next = f(cur);
      if (!next) return "skipped";
      if (await this.store.replace(idHash, cur.version, { ...next, version: cur.version + 1 })) return "written";
    }
    throw new SessionError("contention");
  }
}
In-memory store for teststs
export class MemorySessionStore implements SessionStore {
  private readonly rows = new Map<string, Session>();
  async get(k: string) { return this.rows.get(k) ?? null; }
  async create(s: Session) {
    if (this.rows.has(s.idHash)) throw new Error("duplicate session id");
    this.rows.set(s.idHash, s);
  }
  async replace(k: string, expectedVersion: number, next: Session) {
    const cur = this.rows.get(k);
    if (!cur || cur.version !== expectedVersion) return false;
    this.rows.set(k, next);
    return true;
  }
  async revokeAllForUser(userId: string, at: number) {
    let n = 0;
    for (const [k, s] of this.rows) {
      if (s.userId !== userId || (s.revokedAt !== undefined && s.revokedAt <= at)) continue;   // sessions in grace are cut too
      this.rows.set(k, { ...s, revokedAt: at, version: s.version + 1 });
      n++;
    }
    return n;
  }
}
Cookie attributeValueWhy
Name__Host-sidForces Secure, Path=/ and no Domain: no subdomain can set or read it
HttpOnlyonScript cannot read the id
SameSiteLaxBlocks most cross-site sends; Strict for admin consoles
Max-Ageunset or absolute timeoutThe server decides validity, not the browser
▭On the whiteboard · Choices3 points
01
ids
256-bit random, base64url, hashed at rest; format checked before any lookup
02
lifetime
idle slides in 1-minute steps; absolute fixed at login and carried through rotation
03
concurrency
every write is CAS with 3 bounded retries; a touch can lose, a revoke cannot be undone
!Device binding is a signal, not a lock

A user-agent hash catches a cookie replayed from a script or another browser, but browsers update their UA and attackers can copy it. Do not bind to the full IP (mobile users roam). For real binding use a key held by the device (DBSC or mTLS) and treat the UA check as a cheap tripwire.

04Tests that prove the property

vitestts
import { describe, it, expect } from "vitest";

let t = 0;
const cfg = { idleMs: 30 * 60_000, absoluteMs: 12 * 3_600_000, elevatedMs: 10 * 60_000, rotationGraceMs: 10_000, touchEveryMs: 60_000 };
const make = () => new SessionManager(new MemorySessionStore(), cfg, () => t);
const ua = { userAgent: "Mozilla/5.0 test" };

describe("SessionManager", () => {
  it("ends a quiet session at the idle timeout", async () => {
    t = 0; const m = make(); const { id } = await m.login("u1", ua);
    t = cfg.idleMs;
    expect(await m.validate(id, ua)).toEqual({ ok: false, reason: "idle_timeout" });
  });
  it("neither activity nor rotation extends the absolute timeout", async () => {
    t = 0; const m = make(); let { id } = await m.login("u1", ua);
    for (let i = 1; i < 36; i++) {                      // every 20 minutes for 11h40m
      t = i * 20 * 60_000;
      if (i === 18) ({ id } = await m.rotate(id, ua, { elevate: false }));
      expect((await m.validate(id, ua)).ok).toBe(true);
    }
    t = cfg.absoluteMs;
    expect(await m.validate(id, ua)).toEqual({ ok: false, reason: "absolute_timeout" });
  });
  it("never upgrades a pre-login id", async () => {
    t = 0; const m = make();
    const anon = (await m.login("anon", ua)).id;
    const { id } = await m.login("u1", ua, anon);
    expect(id).not.toBe(anon);
    expect(await m.validate(anon, ua)).toEqual({ ok: false, reason: "revoked" });
  });
  it("keeps the old id briefly after elevation, never elevated, and only rotates once", async () => {
    t = 0; const m = make(); const { id: old } = await m.login("u1", ua);
    const { id: fresh } = await m.rotate(old, ua, { elevate: true });
    expect(await m.validate(fresh, ua)).toMatchObject({ ok: true, elevated: true });
    expect(await m.validate(old, ua)).toMatchObject({ ok: true, elevated: false });
    await expect(m.rotate(old, ua, { elevate: true })).rejects.toThrow("already_rotated");
    t = cfg.rotationGraceMs;
    expect(await m.validate(old, ua)).toEqual({ ok: false, reason: "revoked" });
  });
  it("kills a cookie replayed from another client", async () => {
    t = 0; const m = make(); const { id } = await m.login("u1", ua);
    expect(await m.validate(id, { userAgent: "curl/8.0" })).toEqual({ ok: false, reason: "device_mismatch" });
    expect(await m.validate(id, ua)).toEqual({ ok: false, reason: "revoked" });
  });
});

05Extensions, follow-ups, how to say it

  • Session list: index by userId so users can see and end their devices; show coarse location and last-seen, never the id.
  • Step-up: sensitive actions require elevated; the elevation window is short and capped by the absolute timeout.
  • Distributed store: Redis with WATCH/MULTI or a Lua script for CAS, TTL at absoluteExpiresAt; Postgres with UPDATE ... WHERE version = $n.
  • Audit: emit login, rotate, revoke and device-mismatch events with the id hash prefix, not the id.
Q1Why a grace window on rotation at all?+

The browser may have requests in flight carrying the old cookie when the new one is set. Without grace they fail and the user sees random errors. The grace is seconds long and never carries elevated rights.

Q2What does revokeAll do to a session in its grace window?+

It cuts it immediately: the store treats any revokedAt later than now as still live and overwrites it. Password change must leave nothing behind.

Q3Why not a JWT instead of a server-side session?+

You lose instant revocation and idle timeout, and gain nothing for a single web app. JWTs suit service-to-service calls with short lifetimes.

“How to say it in the room

"Ids are 256 random bits in a __Host- cookie, stored only as a hash. Login always mints a new id and revokes the pre-login one, so fixation is impossible. Idle timeout slides in one-minute steps; the absolute timeout is fixed at login and carried through rotation. Every write is compare-and-set with bounded retries, so a racing request refreshing last-seen can never undo a logout. Device binding is a UA tripwire that kills the session on mismatch."

Test yourself

Validate does `store.put({ ...s, lastSeenAt: now })` with no version check. Logout runs between the get and the put. What happens?

Finished this one?
Tick it and it shows as done on the roadmap and the course.