--- status: accepted date: 2026-04-29 last-updated: 2026-05-12 decision-makers: R&D Lead tags: [security, backend, infrastructure] --- # Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest ## Context and Problem Statement [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) fixed the authentication flow: the BFF holds id/access/refresh tokens server-side and the browser only carries an opaque `__Host-portal_session` cookie. This ADR pins where the session lives, in what shape, with which TTL policy, and with which security posture. Two non-negotiable constraints frame the choices: - the BFF must be able to **revoke a session immediately** (logout, security incident, admin action); - the session payload contains tokens that, if exfiltrated from Redis, would let an attacker impersonate the user — so storage must protect against snapshot or memory-dump leaks, not only against network-level theft. ## Decision Drivers - Immediate, server-side revocation (logout, force-logout-everywhere). - Defense in depth at the storage layer — Redis security is necessary but not sufficient. - Production HA with a self-hosted Redis (per the on-prem constraint of the project). - Battle-tested integration in NestJS, no bricolage on a security-critical path. - TTL policy aligned with enterprise norms (idle + absolute), without harming UX. - Listing and revoking active sessions for a given user must be possible without a second persistence layer. - Configuration is env-driven; no host or secret in source. ## Considered Options ### Storage backend - **Redis (self-hosted).** (Chosen.) - PostgreSQL session table. - In-memory (single-node only). - Cookie-session (signed payload in the cookie itself). ### NestJS integration - **`express-session` + `connect-redis` middleware** mounted under the Express adapter. (Chosen.) - A custom Redis-backed session service. - A NestJS-specific wrapper (`nestjs-session`, etc.). ### Session ID format - **Opaque crypto-random ≥ 256 bits.** (Chosen.) - JWT-as-session-id. ### Encryption at rest - **AES-256-GCM applied by the BFF before tokens are stored.** (Chosen.) - No application-level encryption (rely on Redis ACL + TLS only). ### TTL policy - **Idle (sliding) 30 min + absolute 12 h.** (Chosen.) - Shorter idle (e.g. 15 min) — friction. - Longer absolute (e.g. 24 h) — risk window. - No idle expiry. ### Production topology - **Redis Sentinel HA (3+ nodes).** (Chosen.) Operational details deferred to a phase-3 infrastructure ADR. - Redis Cluster. - Single node (dev only). ## Decision Outcome **Backend.** Redis, self-hosted, accessed via the standard `express-session` + `connect-redis` middleware mounted at NestJS bootstrap (the BFF runs on the Express adapter per [ADR-0005](0005-backend-stack-nestjs.md)). The Redis client is `ioredis` (Sentinel support, mature in Node). **Cookie ↔ session binding.** The browser carries `__Host-portal_session` (defined in [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md)) containing a 256-bit crypto-random session id signed with `SESSION_SECRET`. The BFF dereferences the id against Redis to retrieve the session payload. The browser sees nothing else. **Session payload (Redis value, JSON-encoded):** ```ts type SessionPayload = { userId: string; audience: 'workforce' | 'customer'; claims: { // curated subset of the id_token, never the full token sub: string; oid: string; tid: string; // home tenant of the user name?: string; preferred_username?: string; roles?: string[]; }; tokens: EncryptedBlob; // AES-256-GCM ciphertext of { id_token, access_token, refresh_token, expiresAt } createdAt: number; // epoch ms lastSeenAt: number; // epoch ms — updated on each request absoluteExpiresAt: number; // epoch ms — createdAt + SESSION_ABSOLUTE_TIMEOUT ip?: string; // remote IP at session creation, for audit userAgent?: string; // UA at session creation, for audit }; ``` **Encryption at rest.** The **entire serialised session payload** is encrypted with **AES-256-GCM** before it lands in Redis, using a key read from `SESSION_ENCRYPTION_KEY` (32 bytes, base64url-encoded). A fresh 96-bit IV is generated per write; the GCM auth tag is appended; the envelope is a versioned dot-delimited string (`v1...`, all base64url) so the algorithm / key derivation can rotate without a flag-day re-encryption. The encryption hook is the `connect-redis` `serializer` option (no `RedisSessionStore` wrapper class needed) — encryption runs on `set`, decryption on `get`. An earlier draft of this ADR scoped encryption to just the `tokens` sub-field; v1 ships with whole-payload encryption because the session also carries claims (`oid`, `tid`, `preferred_username`, …) that qualify as PII under GDPR — encrypting the envelope is strictly stronger and removes the need to classify fields one by one. Key rotation procedure (overlap window, re-encryption) is deferred to a future operations ADR. **Redis client.** `ioredis` for the BFF-wide shared connection. `connect-redis` v9 was rewritten against `node-redis` v4 and no longer accepts `ioredis` directly; the BFF ships a small adapter (`session/ioredis-connect-redis-adapter.ts`) that shapes the six commands `connect-redis` actually calls (`get`, `set` with `{expiration:{type:'EX',value}}`, `expire`, `del`, `mGet`, `scanIterator`) to the `node-redis` surface. Keeping `ioredis` as primary means the rest of the BFF (OBO cache, future Redis consumers, Sentinel topology) stays on a single client and a single connection pool; the adapter is the only place that knows about the impedance mismatch. **TTL policy.** | Field | Default | Source of truth | | ---------------------- | ------- | ---------------------------------- | | Idle (sliding) timeout | 30 min | `SESSION_IDLE_TIMEOUT_SECONDS` | | Absolute timeout | 12 h | `SESSION_ABSOLUTE_TIMEOUT_SECONDS` | Mechanics: - The Redis key carries an `EXPIRE` matching the idle timeout; every authenticated request refreshes it via `connect-redis`'s `touch`. - `absoluteExpiresAt` is recorded at session creation and **checked on every request**. If exceeded, the BFF deletes the session key and returns 401. - The two checks are independent: a session ends at whichever timeout fires first. Defaults are policy decisions, not technical limits — they can be tuned per environment via env without code changes. **Token refresh.** Access tokens (Entra-issued, ~1 h lifetime) are refreshed via `acquireTokenSilent` (MSAL Node) when an authenticated handler observes the access token is within ~5 min of expiry. The refresh token (Entra workforce, ~90-day sliding) is stored in the encrypted blob alongside the access token. Refresh-token rotation is enabled (cf. ADR-0009). **Revocation.** - `POST /auth/logout` deletes the session key immediately (`DEL session:{id}`). - An admin "log out user X everywhere" operation lists keys via a secondary index `user_sessions:{userId}` (a Redis set of session ids maintained on session create/destroy) and `DEL`-s them. - An admin "log out everyone" operation is intentionally not provided as a one-shot endpoint — it would be implemented as a runbook, not a feature, to avoid creating an obvious abuse vector. **Active-sessions listing.** The optional `user_sessions:{userId}` index supports listing of active sessions per user (for an admin dashboard or a user-side "my active sessions" view). No PostgreSQL mirror — historical trace lives in the audit log (future ADR). **Topology.** - **Production:** Redis Sentinel with at least 3 nodes (1 master, 2 replicas, 3 sentinel processes for quorum), TLS in transit, ACL-restricted credentials, AOF persistence at least every second. Hosting and operational specifics (k8s operator, backup strategy, monitoring) are deferred to a phase-3 infrastructure ADR. - **Development:** a single Redis instance (Docker container or bare process), no TLS, no persistence required. Same connection-string interface (`REDIS_URL` or Sentinel-style `REDIS_SENTINEL_HOSTS` / `REDIS_SENTINEL_NAME`) so code paths are identical. **Configuration (env-driven).** | Variable | Purpose | | --------------------------------------------------------------- | ------------------------------------------------------- | | `REDIS_URL` _or_ `REDIS_SENTINEL_HOSTS` + `REDIS_SENTINEL_NAME` | connection target | | `REDIS_PASSWORD` | client-side ACL credential | | `REDIS_TLS` | `'true'`/`'false'` — required `true` in prod | | `SESSION_SECRET` | cookie-signing HMAC secret (also used by ADR-0009) | | `SESSION_ENCRYPTION_KEY` | 32-byte base64 AES-GCM key for the encrypted token blob | | `SESSION_IDLE_TIMEOUT_SECONDS` | default `1800` | | `SESSION_ABSOLUTE_TIMEOUT_SECONDS` | default `43200` | The BFF refuses to start if any required variable is missing or malformed (e.g. encryption key not exactly 32 bytes after base64 decode). ### Consequences - Good, because server-side state means a session can be invalidated by a single Redis command, instantly, with no race window. - Good, because AES-GCM at rest defends against Redis snapshot, RDB dump, or memory-inspection scenarios — exfiltrated ciphertext is useless without the key. - Good, because the TTL policy (30 min idle / 12 h absolute) is consistent with enterprise standards and well within Entra's token lifetimes. - Good, because Sentinel gives a clean failover story for self-hosted prod — no SaaS dependency. - Good, because `connect-redis` is mature and used by thousands of Node services; no bricolage on a security-critical path. - Good, because the `user_sessions:{userId}` secondary index covers "list / revoke per user" without a Postgres mirror. - Bad, because we now manage two long-lived secrets (`SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`) — both must be backed up, rotated, and distributed safely. The rotation procedure is a real operational item (future ADR). - Bad, because Sentinel deployment, monitoring, and persistence are non-trivial — flagged for the phase-3 infrastructure ADR; this is real ops work, not a footnote. - Bad, because 30-min idle is short for tasks like writing a long form. Mitigation: the SPA can fire a lightweight heartbeat to `/auth/me` (already an existing route) on user activity to keep the session warm; this remains a UX detail, not an architectural one. - Bad, because the secondary `user_sessions:{userId}` index adds two extra Redis writes per session lifecycle event and must be kept consistent — mitigated by treating it as best-effort (orphan entries are tolerated; expired session ids are cleaned during the next list/revoke operation). - Neutral, because no Postgres mirror means "active sessions" exists only in Redis; if Redis is wiped, all users are logged out. That is the intended behaviour — sessions are ephemeral by design. ### Confirmation - `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the shared `ioredis` client (via `ioredis-connect-redis-adapter.ts`). - The `connect-redis` `serializer` option applies AES-256-GCM encryption to the JSON-encoded session on `set` and decryption on `get` (see `session/session-crypto.ts`). The serializer rejects (and logs an audit event) any record whose authentication tag fails to verify — this catches both tampering and a wrong key. - The session id is generated via `crypto.randomBytes(32).toString('base64url')`. - Cookie name `__Host-portal_session` (per ADR-0009); cookie attributes asserted by integration tests. - `absoluteExpiresAt` is checked in a global NestJS interceptor before any controller logic; expiry triggers `DEL` and 401. - `user_sessions:{userId}` membership is maintained on session create / destroy; "log out everywhere" is exposed as a controller method on an admin module (future). - Redis client is `ioredis`; in prod, configured via Sentinel with TLS; in dev, against `REDIS_URL`. - Integration tests cover: login → session created; subsequent request → idle TTL refreshed; 30 min idle → 401; 12 h elapsed → 401 even under activity; logout → key deleted; tampered token blob → reject + audit; admin force-logout → all sessions for the target user deleted. - `helmet` and the BFF startup checks reject missing or malformed `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`. ## Pros and Cons of the Options ### Storage backend #### Redis self-hosted (chosen) - Good, because in-memory fast, native TTL, sub-ms latency on session reads on the hot path. - Good, because immediate revocation, atomic operations, mature ecosystem. - Good, because aligned with the on-prem constraint without a SaaS dependency. - Bad, because operating a Sentinel cluster is non-trivial — flagged for the phase-3 infra ADR. #### PostgreSQL session table - Good, because we already need PostgreSQL ([ADR-0006](0006-persistence-postgresql-prisma.md)) — no new component. - Bad, because every authenticated request would hit a relational DB for a hot, ephemeral key-value lookup. Slower, more contention, less natural TTL. - Bad, because mixing transient session state with durable business data conflates concerns. #### In-memory - Bad, because no HA, sessions lost at every restart, scale-out impossible. #### Cookie-session (signed payload in cookie) - Good, because stateless server. - Bad, because revocation is impossible without an extra blacklist; the session keeps "working" until the cookie naturally expires. Unacceptable for a security-sensitive portal. - Bad, because the cookie carrying tokens (even encrypted) is sent on every request — header bloat and a more attractive target. ### NestJS integration #### `express-session` + `connect-redis` (chosen) - Good, because the de facto standard pair in Node, mature, well-understood. - Good, because runs natively under the Express adapter NestJS already uses. - Bad, because two libraries to keep up to date — manageable. #### Custom Redis service - Bad, because reinvents `connect-redis` poorly. Bricolage on a security path. #### NestJS-specific wrapper - Neutral, because adds nothing meaningful over the `express-session` + `connect-redis` baseline. ### Session ID format #### Opaque random (chosen) - Good, because zero information leaked client-side; revocation is just a `DEL`. - Good, because compatible with `__Host-portal_session` and the BFF pattern. #### JWT-as-session-id - Bad, because brittle (signature, claims surface), revocation requires a blacklist, and exposes structure that opaque IDs hide. ### Encryption at rest #### AES-256-GCM (chosen) - Good, because authenticated encryption — tamper detection comes for free. - Good, because defends against Redis exfiltration scenarios that go beyond the network/ACL boundary. - Bad, because adds a second secret to manage and rotate. #### No application-level encryption - Bad, because relies entirely on Redis being uncompromisable. Anyone who reads a memory dump or RDB snapshot gets working tokens. ### TTL policy #### 30 min idle / 12 h absolute (chosen) - Good, because aligned with enterprise practice and well within Entra refresh-token lifetimes. - Good, because limits the window of damage from a leaked session id. - Bad, because mildly UX-disruptive on long inactive periods (mitigated by heartbeat). #### 15 min idle / 8 h absolute - Good, because tighter security. - Bad, because more user friction; would require explicit UX consideration. #### 60 min idle / 24 h absolute - Good, because more user-friendly. - Bad, because doubles the risk window after credential loss / session theft. ### Production topology #### Redis Sentinel HA (chosen) - Good, because automatic failover, replica reads possible, simple to reason about. - Good, because matches the operational profile of an on-prem deployment. #### Redis Cluster - Good, because scales beyond a single master. - Bad, because more complex; we don't need cross-shard scaling for sessions at expected volume. ## More Information - OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html - `express-session`: https://github.com/expressjs/session - `connect-redis`: https://github.com/tj/connect-redis - `ioredis`: https://github.com/redis/ioredis - Redis Sentinel: https://redis.io/docs/management/sentinel/ - AES-GCM (Node `crypto.createCipheriv`): https://nodejs.org/api/crypto.html#class-cipheriv - Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS on Express adapter), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), and the future ADRs for MFA enforcement, audit trail, and on-prem infrastructure (Sentinel deployment specifics, secret rotation procedure).