ADR-013: Web Session Strategy — Signed Cookies with Refresh Token

On this page

Status

Accepted

Context

craig-web currently uses tower_sessions::MemoryStore for HTTP session management (services/craig-web/src/main.rs:56). This prevents horizontal scaling: sessions are pinned to a single process, lost on restart, and cannot be shared across instances. For Kubernetes deployment (ADR-011), craig-web must scale horizontally without session affinity.

Session data stored today (services/craig-web/src/auth.rs:14-20):

  • access_token — JWT from the IdP (~1.2 KB)

  • refresh_token — not currently stored (gap)

  • usernamepreferred_username from JWT claims (~20 bytes)

  • sub — user UUID (36 bytes)

  • roles — role name array (~50 bytes)

  • email — optional (~30 bytes)

  • pkce_verifier — ephemeral, only during auth flow (~43 bytes)

  • oauth_state — ephemeral, only during auth flow (~21 bytes)

Total per session: ~1.4 KB typical (dominated by the JWT). Well within the 4 KB cookie size limit.

Four approaches were evaluated:

Criterion MemoryStore (current) Redis PostgreSQL Signed Cookies

Horizontal scaling

No

Yes

Yes

Yes — unlimited

New infrastructure

None

Redis cluster

None (existing DB)

None

Crash resilience

Sessions lost

Survives

Survives

Stateless — survives anything

Server-side revocation

Yes (delete from map)

Yes (delete key)

Yes (delete row)

Via refresh token rejection

Latency per request

~0 ns (in-process)

~1 ms (network)

~2-5 ms (network + query)

~0.1 ms (crypto verify)

12-factor compliance

Violates (process state)

Good

Good

Excellent (stateless)

Decision

Replace MemoryStore with signed/encrypted cookie-based sessions containing the access token and refresh token.

The session cookie is encrypted with AES-GCM and signed with HMAC using the SESSION_SECRET key (already in WebSettings).

Contents:

  • access_token — the IdP-issued JWT, forwarded to backend APIs as Authorization: Bearer

  • refresh_token — used to obtain a new access_token when the current one expires

  • sub — user UUID (for display and middleware checks)

  • username — preferred_username (for UI greeting)

  • roles — role array (for nav visibility and client-side checks)

  • email — optional

Request Flow

On each authenticated request:

  1. Middleware decrypts the session cookie

  2. If access_token is not expired → proceed, forward token to backend APIs

  3. If access_token is expired → use refresh_token to obtain a new access_token from the IdP token endpoint

    1. On success: update the cookie with the new access_token and (optionally rotated) refresh_token

    2. On failure (refresh token revoked, expired, or IdP unreachable): clear the cookie and redirect to login

  4. Backend APIs independently validate the JWT signature — craig-web does not need to verify signatures

Revocation

Revocation is handled by refresh token rejection:

  • When an administrator revokes a user’s access (password reset, account suspension, etc.), the IdP invalidates the refresh token

  • The next time craig-web attempts to refresh, the IdP rejects the request

  • craig-web clears the cookie and redirects to login

  • The maximum window between revocation and session termination is one access_token TTL (30 minutes by default)

An event-driven in-process blocklist (via subscribe_exclusive() on security.user.revoked events) is deferred to Phase 11 (Portals) if near-instant revocation is needed.

Key Requirements

  • SESSION_SECRET MUST be at least 64 bytes (for AES-256 key derivation + HMAC-SHA256). Startup validation enforces this.

  • Cookie attributes: SameSite=Strict, HttpOnly=true, Secure (configurable for plain HTTP devstack). CSRF is enforced by the verify_csrf middleware (Sec-Fetch-Site / Origin / Referer verification on state-changing requests, #763); SameSite=Strict is a second layer (the session cookie is not read during the cross-site OIDC callback, which relies on the separate Lax PKCE cookie, so Strict does not break login).

  • session_max_age configurable (default: 1800 seconds, matching typical access token TTL)

Rationale

  • The JWT IS the session: The access token is the authoritative proof of authentication. All other session fields are derived from it and cached for convenience. This is a textbook case for cookie-based sessions.

  • Zero infrastructure: No Redis, no PostgreSQL session table, no cleanup crons. The SESSION_SECRET already exists.

  • Unlimited horizontal scaling: Every craig-web instance can decrypt any session cookie. No session affinity, no sticky sessions, no shared state.

  • Crash resilience: Sessions survive restarts. The current MemoryStore loses all sessions on restart, forcing all users to re-login.

  • Small session size: ~1.4 KB is well within the 4 KB cookie limit even after encryption overhead (~200 bytes).

  • Refresh token in cookie: Enables automatic token renewal without user interaction and provides the revocation mechanism.

Consequences

  • Horizontal scaling of craig-web becomes possible with zero infrastructure changes.

  • Server-side revocation has a 30-minute maximum delay (access token TTL). This is acceptable for alpha; tightenable via shorter TTL or event-driven blocklist later.

  • The refresh token is stored client-side (encrypted). If SESSION_SECRET is compromised, all sessions are compromised. Key rotation support is recommended as a future enhancement.

  • Cookie size (~1.6 KB with encryption overhead) is larger than a typical session ID cookie (~50 bytes). This is negligible for an internal application but may be a consideration for high-frequency API polling.

  • Existing user sessions are invalidated on migration (they are in-memory, not in cookies). Users re-login once. This is acceptable for alpha.

Edit this page · latest