Skip to content

Session Management

Statement

ClaimGuard sessions are stateless. After a successful login, the server issues a signed JWT (HMAC-SHA256, see Cryptography) with a fixed expiry; the client carries it on subsequent requests as a Bearer token. There is no server-side session store, no session cookie, and no session identifier separate from the JWT itself.

Logout is a client-side operation — the client discards the token. The server does not maintain a revocation list, so a stolen token remains valid until its exp timestamp. This is the standard trade-off of stateless JWTs and is the largest gap on this page.

Implementation

Token issuance

  • Endpoint: POST /api/auth/login (server/src/routes/auth.js).
  • Payload: { id, email, app_role, org_ref, iat, exp }.
  • Signing: jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY }) with jsonwebtoken. JWT_SECRET is mandatory at boot (server/src/middleware/auth.js); the server refuses to start without it. See Cryptography for the algorithm (HS256).

Token expiry

  • JWT_EXPIRY defaults to 24h and is configurable per environment via the standard secrets.js Secret Manager bootstrap (see Secrets management).
  • The default is the trade-off between user friction (re-login cadence) and blast radius of a stolen token.

Token transport

  • Header: Authorization: Bearer <jwt> is the supported transport.
  • No cookie storage. The application does not set or read authentication cookies. This avoids the CSRF surface that a cookie-based session would introduce.
  • Refresh tokens are not used. The single 24h token is issued at login and the user re-authenticates after expiry.

Token verification

server/src/middleware/auth.js (requireAuth) verifies every request:

  1. Extract the Bearer token from Authorization.
  2. jwt.verify(token, JWT_SECRET) — fails on signature mismatch, exp expiry, or malformed payload.
  3. Re-check is_active against the users table. Disabled accounts are rejected with 401 code: USER_DISABLED regardless of token validity, so an admin disable takes effect on the user's next request — not at the next 24h boundary.
  4. Populate req.user = { id, email, display_name, full_name, app_role, org_ref } from the current row, not the JWT payload. Role and org changes propagate on the next request as well.
  5. The downstream route then runs role checks (see Authorization) and org-scoped queries.

A failed verification returns 401; the caller must log in again.

Logout

  • Endpoint: POST /api/auth/logout exists for symmetry, but the server-side action is essentially a no-op. The route handler comment in server/src/routes/auth.js lines 135-139 calls this out: "If we add a sessions table in the future, we would invalidate the session here."
  • Client behavior: delete the locally stored JWT.

Concurrent sessions

Because tokens are stateless and not bound to a session ID, a single user can have multiple valid tokens simultaneously (e.g., one per device). There is no per-user session count or per-device session inventory.

What this means in practice

Scenario Behavior today
User logs out Local JWT discarded; the token itself remains valid until exp.
Admin disables a user Outstanding tokens are rejected on the next request — requireAuth re-checks is_active per call and returns 401 code: USER_DISABLED.
Admin changes a user's role The new role takes effect on the user's next request. requireAuth rebuilds req.user from the current users row, so requireAdmin / requireSuperAdmin see the updated role without a re-login.
User changes password Existing tokens remain valid until exp. Password change does not invalidate tokens.
JWT_SECRET is rotated All tokens issued under the old secret immediately become invalid; every user must re-login. This is the operational reason JWT_SECRET rotation is comms-coordinated (see Cryptography roadmap A2.5).

Status

implemented — verified 2026-04-29.

What's in place:

  • Stateless JWTs signed with mandatory JWT_SECRET (HS256, ≥32-byte secret).
  • 24h default expiry, configurable.
  • Bearer-token transport; no cookie surface; no CSRF exposure on the auth path.
  • No development-only or fallback session path.
  • Per-request is_active re-check and role refresh in requireAuth — disable / demote takes effect on the user's next request rather than waiting for token expiry. Covered by an integration test in server/tests/auth/users-crud.test.js.

Known gaps

  • No server-side revocation of stolen tokens. Token theft is the remaining window: a stolen token of a still-enabled user is valid until exp. Mitigated by the 24h expiry; a shorter-lived token + refresh model or a revoked_jti table would close this more decisively.
  • No per-session inventory or "log out all devices" affordance for end users. Listed as a roadmap item for the first customer who asks.
  • No rotating refresh-token / sliding-session model. Today the user re-logs at the 24h boundary; a refresh-token model would let the access token shrink to 5–15 minutes while keeping the user session continuous.

Roadmap

  • Token revocation list — a small revoked_jti table consulted by requireAuth, populated on logout / password-change / token-theft events. Trade-off: re-introduces a small amount of session state.
  • Refresh-token model with short-lived (5–15 min) access tokens and longer-lived refresh tokens, gated on the same revocation list.
  • Per-user session inventory with a "log out everywhere" UX, practical only after the revocation-list model is in.
  • JWT_SECRET rotation cadence (A2.5) — the comms-coordinated playbook is now written (JWT_SECRET rotation runbook); the first rotation under that playbook lands at the next 90-day boundary or sooner on suspected leak. Rotation invalidates every token at once, so it cannot be silent.