4.4 KiB
ADR-020 — Stateful Authentication via Spring Session JDBC
Date: 2026-05-17 Status: Accepted Issue: #523
Context
PR #521 (closing #520) introduced AuthTokenCookieFilter to unblock a production deploy.
The filter promotes an auth_token cookie — which contains the full HTTP Basic credential
(Basic <base64(email:password)>) — to an Authorization header so browser-direct /api/*
calls authenticate correctly behind Caddy.
This model has three concrete problems:
- Cookie = credential. A stolen
auth_tokencookie leaks the user's password in base64-encoded plaintext. No decode step is needed; the cookie value is directly usable as a credential forever. - No server-side revocation. Logout deletes the local cookie but the credential
remains valid until the 24 h
Max-Ageelapses. An attacker who copied the cookie before logout retains access. - No audit signal. There is no server-side record of login or logout events. Observability and compliance tooling cannot reconstruct "who was logged in when".
Additionally, Nora flagged that url.protocol === 'https:' in login/+page.server.ts is
incorrect behind Caddy: SvelteKit sees http, so Secure=false was set on the credential
cookie in production, transmitting it in cleartext from Caddy to the browser on any network
path without TLS.
Decision
Replace the auth_token / AuthTokenCookieFilter model with Spring Session JDBC:
- A
POST /api/auth/loginendpoint in a newauthpackage authenticates withemail + password, creates a server-side session record in PostgreSQL, and returns theAppUserJSON in the response body. - The response sets an opaque
fa_sessioncookie (HttpOnly,SameSite=Strict,Securein non-dev profiles,Max-Age=28800— 8 h idle timeout) that contains only the session ID, never a credential. - A
POST /api/auth/logoutendpoint invalidates the session record immediately. Subsequent requests carrying the same cookie return 401. AuthTokenCookieFilteris deleted in the same PR. No transitional coexistence period.- Cookie name
fa_session(not the defaultSESSION) minimises framework fingerprinting.
Session storage uses the canonical spring_session / spring_session_attributes tables,
re-introduced via V67__recreate_spring_session_tables.sql (dropped by V2 when the
dependency was previously removed as unused).
Idle timeout: 8 h (MaxInactiveIntervalInSeconds = 28800). No 24 h absolute cap is
implemented in Phase 1 — the 8 h idle bound contains the risk to one workday. A weekend-long
active session is acceptable given the family-archive threat model. The absolute cap and
additional revocation paths (password-change, admin force-logout) land in Phase 2 (#524).
Alternatives Considered
Stay on Basic cookie + add a server-side revocation table
Keeps the credential-in-cookie problem. Implementing a revocation table would re-invent Spring Session badly — we'd write bespoke session storage that already exists and is well-tested upstream.
JWT (stateless)
Opaque revocation is simpler than JWT revocation (token introspection or short-lived tokens
- refresh). The cluster is single-node; session affinity is not a constraint. Stateless tokens buy complexity without benefit here. JWKS infrastructure and refresh-token rotation are unnecessary for a family archive with < 50 concurrent users.
Keep auth_token cookie but add AuthTokenCookieFilter improvements
The root problem is that the cookie contains the credential. No amount of filter hardening fixes that. Nora's P1 flag stands until the credential leaves the cookie.
Consequences
- One breaking deploy. All existing sessions (the
auth_tokencookies) become inert on the next request after the deploy. The SvelteKithandleAuthhook redirects to/login?reason=expired; a banner renders. Users re-login. No data loss. - ~2 KB per active session in PostgreSQL (
spring_session_attributesstores the serialisedSecurityContext). With < 50 family members, this is immaterial. - Session cleanup task runs on the default Spring Session JDBC schedule (every 10 min). No custom job needed.
- Caddy / infrastructure unchanged.
forward-headers-strategy: nativealready ensuresSecurecookies work correctly behind the reverse proxy. - Dev profile:
application-dev.yamlsetssecure: falseon the session cookie so local HTTP dev (port 5173 → 8080) works without TLS.