Files
apf_portal/docs/decisions/0010-session-management-redis.md
T
Julien Gautier ed6cca499d
CI / commits (pull_request) Successful in 2m46s
CI / scan (pull_request) Failing after 2m47s
CI / check (pull_request) Successful in 4m24s
CI / a11y (pull_request) Successful in 1m51s
CI / perf (pull_request) Successful in 3m24s
feat(portal-bff): session middleware with AES-256-GCM at rest per ADR-0010
mount express-session + connect-redis at bootstrap on top of the
shared ioredis client. the full json payload is encrypted with
aes-256-gcm before it reaches redis; envelope is versioned
(v1.<iv>.<tag>.<ciphertext>, base64url) so the algorithm or key
derivation can rotate without a flag-day re-encryption.

scope is intentionally infrastructure-only — middleware mounted,
req.session available downstream, cookie set on first write,
encryption-at-rest active. populating req.session.user from
/auth/callback, /me, /auth/logout, and the absolute-timeout
interceptor land in follow-ups.

notable shape choices captured in ADR-0010 (amended here):
- encryption at rest applies to the whole payload, not just a tokens
  sub-field. the session also carries pii claims (oid, tid,
  preferred_username) — encrypting the envelope removes the need to
  classify fields one by one and costs essentially the same.
- connect-redis v9 was rewritten for node-redis v4 and dropped
  ioredis. rather than swap the whole bff to node-redis, a small
  adapter shapes the six commands connect-redis actually calls
  (get / set with {expiration:{type:'EX',value}} / expire / del /
  mGet / scanIterator) to look like node-redis. the rest of the
  bff stays on a single shared ioredis client.

session id: crypto.randomBytes(32).toString('base64url')
cookie name: __Host-portal_session in production, portal_session in
dev (the __Host- prefix mandates Secure which dev http can't
provide). httpOnly + sameSite=lax + path=/. resave:false,
saveUninitialized:false, rolling:true; cookie maxAge follows
SESSION_IDLE_TIMEOUT_SECONDS (default 1800).

env: SESSION_ENCRYPTION_KEY is mandatory (32 bytes after base64url
decode); rejected at boot via a new assertSessionEncryptionKey()
mirroring the other pre-flight validators. SESSION_IDLE_TIMEOUT_SECONDS
and SESSION_ABSOLUTE_TIMEOUT_SECONDS are optional with adr-aligned
defaults (1800 / 43200).
2026-05-12 17:56:52 +02:00

17 KiB

status, date, last-updated, decision-makers, tags
status date last-updated decision-makers tags
accepted 2026-04-29 2026-05-12 R&D Lead
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 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). 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 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.<iv>.<tag>.<ciphertext>, 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) — 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.
  • 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