Skip to main content

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.go returns a JWT in JSON.
  • backend/internal/http/middleware/auth.go reads Authorization: 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 via pg.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 name: __Host-evalium_session
  • Attributes: HttpOnly; Secure; SameSite=Lax; Path=/
  • __Host- invariants MUST be enforced:
    • Secure is required
    • Path=/ is required
    • Domain MUST 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-store
    • Pragma: no-cache (legacy intermediaries)
  • Any response that returns session identity or CSRF tokens MUST include:
    • Cache-Control: no-store
    • Pragma: 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_all change).
  • Refresh policy:
    • Refresh/rotate on /auth/verify, and thereafter rotate on the renewal cadence (default: 4 hours) and on privilege/scope change.
  • 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/me or /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 Origin when present; if missing, fall back to strict same-origin Referer.
  • Require X-CSRF-Token header.
  • Require a non-"simple" request profile for unsafe routes (e.g., application/json plus X-CSRF-Token) to avoid form-based CSRF paths; maintain an explicit Content-Type allowlist.
  • SameSite=Lax is 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_all
  • role_ids (uuid[])
  • created_at, last_seen_at
  • idle_expires_at, absolute_expires_at
  • revoked_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_at updates 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.

On session create/refresh:

  • Set Set-Cookie: __Host-evalium_session=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=...
  • Include Cache-Control: no-store and Pragma: 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-store and Pragma: 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 Authorization header 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_hash and look up session.
  • Validate:
    • not revoked
    • not expired (idle + absolute)
  • Build auth.AuthContext with:
    • 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 sessionId to request context for observability (never token or token hash).
  • Include requestId + sessionId in auth/CSRF failure logs.

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
  • Set __Host-evalium_session cookie.
  • Return minimal JSON (no token). Example:
    • { "expiresAt": "<iso8601>" }
  • Set Referrer-Policy: no-referrer (or strict-origin) and avoid third-party assets on the verification route to prevent token leakage.

4b) Auth endpoint rate limiting

  • /auth/login is rate-limited per IP and per identifier (email), with user-enumeration-safe responses.
  • /auth/verify is rate-limited; tokens are single-use.

5) /auth/me and /auth/csrf contract

  • /auth/me returns:
    • identity + scope (non-sensitive)
    • resolved capabilities
    • CSRF token for the session (or a mechanism to fetch it)
  • /auth/csrf returns:
    • CSRF token (session-bound), plus Cache-Control: no-store

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.ResolveCapabilities remains 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_UNAUTHENTICATED
  • AUTH_SESSION_EXPIRED
  • AUTH_CSRF_MISSING
  • AUTH_CSRF_ORIGIN_INVALID
  • AUTH_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)