ADR-013: Web Session Strategy — Signed Cookies with Refresh Token
On this page
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) -
username—preferred_usernamefrom 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.
Cookie Contents
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 asAuthorization: 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:
-
Middleware decrypts the session cookie
-
If
access_tokenis not expired → proceed, forward token to backend APIs -
If
access_tokenis expired → userefresh_tokento obtain a new access_token from the IdP token endpoint-
On success: update the cookie with the new access_token and (optionally rotated) refresh_token
-
On failure (refresh token revoked, expired, or IdP unreachable): clear the cookie and redirect to login
-
-
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_SECRETMUST 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 theverify_csrfmiddleware (Sec-Fetch-Site / Origin / Referer verification on state-changing requests, #763);SameSite=Strictis a second layer (the session cookie is not read during the cross-site OIDC callback, which relies on the separateLaxPKCE cookie, so Strict does not break login). -
session_max_ageconfigurable (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_SECRETalready 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_SECRETis 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.