Plan: Web Session Cookie Migration (ADR-013)
On this page
Status
COMPLETE — merged to main via !81 (2026-04-18). Plan moved to archive index; see plans/archive.adoc Security & Compliance.
| Step | Description | Status |
|---|---|---|
1 |
Write plan, create issue, create branch |
Done (pre-ADR-030) |
2 |
Add cookie-jar dependency with private+signed features |
Done (pre-ADR-030) — see Errata — chose |
3 |
Add Key to AppState |
Done (pre-ADR-030) |
4 |
Create WebSession struct with cookie serialization (serde JSON, encrypted) |
Done (pre-ADR-030) |
5 |
Rewrite auth.rs: login (PKCE in cookie), callback (store session cookie), logout (clear cookie) |
Done (pre-ADR-030) |
6 |
Rewrite middleware.rs: require_auth reads cookie, injects SessionUser into extensions |
Done (pre-ADR-030) |
7 |
Update route handlers: replace Session extractor with Extension<SessionUser> |
Done (pre-ADR-030) — 31 route files |
8 |
Remove tower-sessions dependency entirely |
Done (pre-ADR-030) |
9 |
Update config: validate SESSION_SECRET >= 64 bytes |
Done (pre-ADR-030) |
10 |
Update docker-compose SESSION_SECRET to >= 64 bytes |
Done (pre-ADR-030) |
11 |
Verify: full nextest + 145 E2E pass |
Done (pre-ADR-030) — 1181/1181 nextest, 145/145 E2E |
12 |
Update docs (security.md, CHANGELOG) |
Done (pre-ADR-030) |
Errata
-
Cookie-jar library chosen:
tower-cookies 0.11withprivate+signedfeatures rather thanaxum-extra::PrivateCookieJar. Both encrypt with AES-GCM usingcookie::Key.tower-cookiesuses a middleware layer (CookieManagerLayer) and theCookiesextractor;axum-extrauses a per-handler extractor that must be returned in the response tuple. The middleware-layer model is less invasive (handlers don’t need to change their return types) and matches the existingCookieManagerLayeralready in place for flash messages. Functionally equivalent for the ADR-013 goal. -
Cookie name:
craig-session(notHost-craig-session).Host-prefix requiresSecure+ noDomain+Path=/, which breaks the dev stack whereCRAIG_WEB__SESSION_SECURE=false. TheSecureattribute is still set in production via config. -
Transitive dep bump:
rustls-webpki 0.103.9 → 0.103.12for RUSTSEC-2026-0098 landed as a separate prerequisite commit (93e71a3) — cargo-deny rejected the old version in validate.
Issues: ADR-013
Branch: feature/private-cookie-sessions
Context
craig-web currently uses tower_sessions::MemoryStore for browser sessions. This means:
-
Sessions are lost on container restart (users must re-login)
-
No horizontal scaling (sessions pinned to one instance)
-
In-process HashMap grows unbounded with active users
ADR-013 decided on stateless encrypted cookies. Two attempts using tower-sessions-cookie-store failed due to version incompatibility between tower-sessions 0.15 and tower-sessions-cookie-store 0.5 — the Session extractor doesn’t recognize the cookie store’s layer at runtime.
The solution is axum_extra::extract::cookie::PrivateCookieJar — a first-class Axum extractor for AES-GCM encrypted cookies. No third-party session crate needed.
Scope
In scope:
-
Replace MemoryStore with PrivateCookieJar
-
Typed WebSession struct (serde JSON in encrypted cookie)
-
PKCE state in a separate short-lived cookie (not in the session)
-
Refresh token stored in session cookie
-
SessionUser injected into request extensions by middleware
-
Remove tower-sessions and tower-sessions-cookie-store dependencies
Out of scope:
-
Token refresh on expiry (follow-up work)
-
Server-side revocation blocklist (deferred to Phase 11 per ADR-013)
-
Locale persistence in cookie (keep in session cookie as a field)
Design
Cookie Layout
One encrypted cookie (__Host-craig-session):
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"username": "jane.doe",
"sub": "00000000-0000-0000-0000-000000000001",
"email": "jane@example.com",
"roles": ["caseworker", "supervisor"],
"locale": "en"
}
Encrypted with AES-GCM via axum_extra::extract::cookie::Key. Cookie attributes: HttpOnly, SameSite=Lax, Secure (configurable), Path=/, Max-Age from config.
One plain cookie during OIDC flow only (craig-pkce, short-lived, cleared after callback):
{
"verifier": "...",
"state": "..."
}
Signed (not encrypted) since PKCE verifier is a random nonce — no sensitive data.
Key Architecture Changes
-
AppStategains acookie_key: axum_extra::extract::cookie::Keyfield -
KeyimplementsFromRef<AppState>so PrivateCookieJar can extract it -
Auth handlers return
(PrivateCookieJar, impl IntoResponse)tuples — the jar must be in the response for Set-Cookie to fire -
require_authmiddleware reads the cookie, deserializes WebSession, injectsExtension<SessionUser>— route handlers extractExtension<SessionUser>unchanged -
Route handlers that currently extract
Sessiononly use it forget_session_user()— the middleware handles this, so handlers just useExtension<SessionUser>
Files Modified
| File | Change |
|---|---|
|
Add |
|
Add |
|
Rewrite login/callback/logout to use PrivateCookieJar. Add WebSession struct. Remove tower_sessions::Session usage. |
|
Rewrite require_auth to read PrivateCookieJar, deserialize WebSession, inject Extension<SessionUser> |
|
Remove SessionManagerLayer. Build Key from SESSION_SECRET. Pass to AppState. Remove tower-sessions imports. |
|
Add |
|
Replace |
|
Remove tower-sessions, tower-sessions-cookie-store from workspace deps. Add axum-extra. |
|
Extend SESSION_SECRET to >= 64 bytes |
|
Update session management section |
|
Document migration |
Verification
-
cargo build -p craig-web— zero errors -
cargo clippy -p craig-web — -D warnings— zero warnings -
cargo nextest run --workspace --profile integration— all 1186 pass -
cargo xtask e2ewith seed=42 — 145 pass -
cargo xtask e2ewith seed=9999 — 145 pass -
cargo xtask e2ewith seed=5555 — 145 pass -
Browser manual test: login → navigate → restart craig-web container → refresh page → still logged in (session survives restart)