Pin the session-state architecture: the browser carries only an opaque
crypto-random session id in __Host-portal_session, signed with
SESSION_SECRET. The payload (userId, audience, curated claims, encrypted
tokens, timestamps) lives in self-hosted Redis, accessed via the standard
express-session + connect-redis pair under the NestJS Express adapter.
The id_token / access_token / refresh_token tuple is encrypted with
AES-256-GCM before being stored - per-record IV, GCM auth tag - using
SESSION_ENCRYPTION_KEY. A Redis snapshot or memory dump alone is not
enough to forge a working session; the encryption key must also be
compromised. Tampered or wrong-key records are rejected and audited.
TTL policy: idle 30 min sliding (TTL refreshed on each request) +
absolute 12 h (checked in a global interceptor, triggers DEL on expiry).
Topology: Redis Sentinel (3+ nodes) in prod with TLS and ACL; single node
in dev. Operational specifics deferred to a phase-3 infrastructure ADR.
Revocation is immediate (DEL session:{id}). A secondary index
user_sessions:{userId} supports per-user listing and force-logout. No
PostgreSQL mirror; historical trace lives in the future audit-log ADR.
decisions/README.md index updated. CLAUDE.md gains an explicit 'Sessions'
line pointing to ADR-0010.
15 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | |||
|---|---|---|---|---|---|---|
| accepted | 2026-04-29 | R&D Lead |
|
Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest
Context and Problem Statement
ADR-0009 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-redismiddleware 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). The Redis client is ioredis (Sentinel support, mature in Node).
Cookie ↔ session binding. The browser carries __Host-portal_session (defined in ADR-0009) 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):
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 tokens field is encrypted with AES-256-GCM before serialisation, using a key read from SESSION_ENCRYPTION_KEY (32 bytes, base64-encoded). A fresh 96-bit IV is generated per session and prepended to the ciphertext; the GCM auth tag is appended. A RedisSessionStore wrapper around connect-redis performs encryption on set/touch and decryption on get. Key rotation procedure (overlap window, re-encryption) is deferred to a future operations ADR.
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
EXPIREmatching the idle timeout; every authenticated request refreshes it viaconnect-redis'stouch. absoluteExpiresAtis 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/logoutdeletes 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) andDEL-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_URLor Sentinel-styleREDIS_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-redisis 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.tsconfiguresexpress-session+connect-redisagainst the env-provided Redis target.- The session store is wrapped in a
RedisSessionStorethat applies AES-256-GCM encryption to thetokensfield onset/touchand decryption onget. The wrapper rejects (and logs as 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. absoluteExpiresAtis checked in a global NestJS interceptor before any controller logic; expiry triggersDELand 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, againstREDIS_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.
helmetand the BFF startup checks reject missing or malformedSESSION_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) — 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-redispoorly. Bricolage on a security path.
NestJS-specific wrapper
- Neutral, because adds nothing meaningful over the
express-session+connect-redisbaseline.
Session ID format
Opaque random (chosen)
- Good, because zero information leaked client-side; revocation is just a
DEL. - Good, because compatible with
__Host-portal_sessionand 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/sessionconnect-redis: https://github.com/tj/connect-redisioredis: 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 (NestJS on Express adapter), ADR-0008 (identity model), ADR-0009 (auth flow), and the future ADRs for MFA enforcement, audit trail, and on-prem infrastructure (Sentinel deployment specifics, secret rotation procedure).