ADR 0012: ASVS L3 Web-Session Authentication (Cookie Sessions)
Date: 2025-12-28
Status: Accepted
Context
Prior to this ADR, auth used client-managed Bearer JWTs returned by /auth/verify and parsed by AuthMiddleware:
backend/internal/http/handler/auth.goreturns a JWT in JSON.backend/internal/http/middleware/auth.goreadsAuthorization: Bearer ....- JWT claims carry
userId,tenantId,orgUnitId,roleIds[],orgScopeAll. - Capabilities are resolved server-side from role IDs (
authz.Service), and RLS scope is enforced via GUCs (pg.WithScope).
For an ASVS L3 web application, long-lived client-managed tokens (especially when stored in script-accessible storage) increase account takeover impact if the browser runtime is compromised (e.g., XSS) and provide weak revocation guarantees. The web app needs first-class session lifecycle controls: rotation, idle/absolute expiry, and revocation, with durable audit visibility.
Decision
For the web app (same-site SaaS UI), use server-side sessions identified by an opaque high-entropy token stored in a Secure, HttpOnly cookie.
- The cookie is the sole authentication transport for the web app.
- Sessions carry identity + scope (tenant/org scope + role IDs).
- RBAC and RLS remain unchanged: capabilities continue to resolve server-side from
role_ids, and RLS scope continues to be set viapg.WithScope.
Scope
In scope (web app):
- Cookie-backed sessions only.
- CSRF protections for state-changing routes.
- Session lifecycle controls: idle timeout, absolute lifetime, rotation, renewal, revocation.
- Stable machine-readable auth/CSRF error codes.
Out of scope (this ADR):
- Cross-site embedding and iframe constraints.
- Non-browser API clients.
- Any general API i18n/validation error contract beyond auth/CSRF failures.
Security Requirements (ASVS L3-aligned)
Session token requirements
- Token is high-entropy: 32+ bytes from a CSPRNG, encoded base64url.
- Session tokens are never accepted or emitted via URL/query string and never logged.
- One-time magic-link verification tokens may appear in URLs; they are short-lived,
single-use, must never be logged, and responses must be
Cache-Control: no-store. - Server stores only a keyed hash:
token_hash = HMAC-SHA-256(server_secret, token)(32 bytes)- comparisons are constant-time (token hash and CSRF hash).
Cookie requirements
- Cookie name:
__Host-evalium_session - Attributes:
HttpOnly; Secure; SameSite=Lax; Path=/ __Host-invariants MUST be enforced:Secureis requiredPath=/is requiredDomainMUST NOT be set
- HTTPS is mandatory for the web origin and must enforce HSTS.
- Any response that sets, refreshes, or clears the session cookie MUST include:
Cache-Control: no-storePragma: no-cache(legacy intermediaries)
- Any response that returns session identity or CSRF tokens MUST include:
Cache-Control: no-storePragma: no-cache
Session lifecycle requirements
- Idle timeout: session expires after inactivity (default: 15 minutes).
- Absolute lifetime: session expires after a maximum lifespan (default: 12 hours), regardless of activity.
- Session regeneration:
- A new session token MUST be issued on successful authentication (
/auth/verify) to prevent fixation. - A new session token MUST be issued on privilege/scope changes (e.g., org unit change,
scope_allchange).
- A new session token MUST be issued on successful authentication (
- Refresh policy:
- Refresh/rotate on
/auth/verify, and thereafter rotate on the renewal cadence (default: 4 hours) and on privilege/scope change.
- Refresh/rotate on
- Renewal rotation:
- Rotate the session token every N hours during active use (default: 4 hours) to reduce exposure.
- Use a short overlap window (default: 5 minutes) so in-flight requests remain valid, then invalidate the prior token.
- Rotation overlap is represented by storing a previous token hash with a strict grace expiry (or a small set of valid token hashes with expiries) and enforcing the grace window.
- Revocation:
- Logout MUST revoke the session server-side and clear the cookie.
- Role changes MUST revoke all active sessions for the affected user+tenant to force re-auth.
- Additional revocation triggers include user disabled, tenant membership removed, and org scope changes.
- Cleanup:
- Background job purges expired/revoked sessions after a defined retention window.
- Indexes support cleanup queries.
CSRF requirements (cookies)
- CSRF uses a synchronizer token pattern.
- CSRF token is session-bound, generated server-side, stored server-side, and returned to the client via
/auth/meor/auth/csrf. - CSRF token MUST rotate when the session token rotates.
- CSRF tokens are treated as secrets: never logged, never placed in URLs, and regenerated on session token rotation and on privilege/scope changes.
CSRF checks are mandatory for all unsafe methods across the web app, including auth/session management endpoints (logout, revoke, revoke-all) and any state-changing bulk routes.
For state-changing requests (POST/PATCH/PUT/DELETE):
- Validate
Originwhen present; if missing, fall back to strict same-originReferer. - Require
X-CSRF-Tokenheader. - Require a non-"simple" request profile for unsafe routes (e.g.,
application/jsonplusX-CSRF-Token) to avoid form-based CSRF paths; maintain an explicit Content-Type allowlist. SameSite=Laxis defense-in-depth only; it is NOT the CSRF control.- Fetch Metadata headers (
Sec-Fetch-Site,Sec-Fetch-Mode,Sec-Fetch-Dest) are enforced as defense-in-depth to reject cross-site unsafe requests. This does not replace synchronizer tokens.
Authenticated web endpoints MUST NOT enable cross-origin credentialed CORS. Access-Control-Allow-Credentials
must not be used for browser session routes, and Access-Control-Allow-Origin must never be * on any response
that varies by session.
Implementation Details (tailored to current code)
Implementation plan: docs/architecture/SECURITY/ASVS-L3-SESSION-IMPLEMENTATION.md
1) Session storage (Postgres)
Create a durable session table auth_sessions:
id(uuid, internal surrogate key)token_hash(bytea, UNIQUE, indexed) — HMAC-SHA-256(secret, token)csrf_hash(bytea, indexed) — HMAC-SHA-256(secret, csrf_token)tenant_id,org_unit_id,user_id,scope_allrole_ids(uuid[])created_at,last_seen_atidle_expires_at,absolute_expires_atrevoked_at,revoked_reason(optional)revoked_by(uuid, optional)user_agent(optional, audit visibility only)ip,device_fingerprint(optional, audit visibility only; not enforced)
Operational notes:
last_seen_atupdates are throttled (default: once per 5 minutes) to avoid write hot spots.- Add cleanup indexes:
(idle_expires_at),(absolute_expires_at),(revoked_at),(tenant_id, user_id)
- Never log raw session tokens.
2) Cookie setting/clearing
On session create/refresh:
- Set
Set-Cookie: __Host-evalium_session=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=... - Include
Cache-Control: no-storeandPragma: no-cache
On logout / revoke:
- Clear cookie with an expired cookie:
Set-Cookie: __Host-evalium_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0
- Include
Cache-Control: no-storeandPragma: no-cache
3) Auth middleware changes
Update backend/internal/http/middleware/auth.go:
- Web UI routes are defined via a dedicated router group/config for the browser UI.
- Web app auth uses cookie sessions only.
- If
Authorizationheader is present on web UI routes, reject with stable code:AUTH_HEADER_NOT_ALLOWED
- Web-session auth and header rejection are enforced by router grouping (not ad-hoc path prefix checks) to prevent accidental bypass via routing changes.
- Extract session token from
__Host-evalium_session. - Compute
token_hashand look up session. - Validate:
- not revoked
- not expired (idle + absolute)
- Build
auth.AuthContextwith:tenant_id,org_unit_id,scope_all,role_ids,user_id
- Inject scope into request context via existing
pg.WithScope. - Capabilities resolution stays as-is (
authz.Service.ResolveCapabilities). - Attach
sessionIdto request context for observability (never token or token hash). - Include
requestId+sessionIdin auth/CSRF failure logs.
4) /auth/verify changes (magic link verification)
Update backend/internal/http/handler/auth.go:
On successful magic-link verification:
- Create a new session row:
- derive and store
token_hash - derive and store
csrf_hash - set idle/absolute expiries
- derive and store
- Set
__Host-evalium_sessioncookie. - Return minimal JSON (no token). Example:
{ "expiresAt": "<iso8601>" }
- Set
Referrer-Policy: no-referrer(orstrict-origin) and avoid third-party assets on the verification route to prevent token leakage.
4b) Auth endpoint rate limiting
/auth/loginis rate-limited per IP and per identifier (email), with user-enumeration-safe responses./auth/verifyis rate-limited; tokens are single-use.
5) /auth/me and /auth/csrf contract
/auth/mereturns:- identity + scope (non-sensitive)
- resolved capabilities
- CSRF token for the session (or a mechanism to fetch it)
/auth/csrfreturns:- CSRF token (session-bound), plus
Cache-Control: no-store
- CSRF token (session-bound), plus
6) Logout and session termination endpoints
Future endpoints for the web app (phase 2):
POST /auth/logout- revokes current session, clears cookie
GET /auth/sessions- lists active sessions for the current user (redacted metadata)
POST /auth/sessions/revoke- revoke a specific session (requires CSRF)
POST /auth/sessions/revoke-all- revoke all sessions for current user (requires CSRF, may require step-up later)
All state-changing endpoints require CSRF enforcement and Origin/Referer validation.
7) Capabilities model (unchanged)
Continue to resolve capabilities from role_ids:
authz.Service.ResolveCapabilitiesremains the source of truth.- RLS scope is set from session claims via
pg.WithScope(no change).
8) Auth error codes (stable, machine-readable; i18n-ready)
Auth/CSRF failures MUST return stable codes:
AUTH_UNAUTHENTICATEDAUTH_SESSION_EXPIREDAUTH_CSRF_MISSINGAUTH_CSRF_ORIGIN_INVALIDAUTH_HEADER_NOT_ALLOWED
Error response shape is fixed:
{
"code": "AUTH_CSRF_MISSING",
"message": "optional human text",
"requestId": "optional request id"
}
Clients MUST branch on code, not message. message is optional and non-contractual.
Consequences
Positive
- Session management meets ASVS L3 expectations: server-side control, strong lifecycle, revocation, rotation, fixation resistance.
- Reduces the blast radius of browser token theft by eliminating client-managed long-lived auth tokens.
- RBAC and RLS remain intact and authoritative on the server.
Negative
- Requires session persistence and cleanup mechanics.
- Requires CSRF controls on all state-changing routes.
references (repo)
backend/internal/http/middleware/auth.go(auth transport)backend/internal/http/handler/auth.go(magic-link verification)backend/internal/services/authz/service.go(capability resolution)backend/internal/pg/tx.go(RLS GUCs)