Skip to content

ADR 0002 — Sign-in token strategy (JWT access + rotating refresh)

  • Status: Accepted
  • Date: 2026-04-27
  • Decision drivers: simplicity for an SPA frontend, ability to revoke sessions, replay detection, low overhead on the hot path
  • Resolves: Open Question 1 in wiki/plans/login.md

Context

The login plan needs a decision on how an authenticated session is represented, transmitted, renewed, and revoked. The user's directive: JWT with a short-lived access token (< 6h). The remaining design choice is how the SPA refreshes that token without forcing the user to re-authenticate every few minutes.

Options considered

  1. Long-lived stateless JWT only. Trivial to implement, fast (no DB hits). Loses revocation (a leaked token is valid until it expires) and rotation. Rejected.
  2. Server-side opaque session token in a cookie (Rails-style). Simple, revocable, but requires a DB lookup on every authenticated request. Rejected because we want JWT for the hot path.
  3. Short-lived JWT access + rotating opaque refresh token (chosen). Hot path is stateless JWT validation; renewal path is a small server-side state machine over a RefreshToken table. Best balance of speed, revocability, and replay detection.

Decision

Tokens

  • Access token — signed JWT (HS256, single app secret, env-injected).
  • TTL: 30 minutes default, configurable via env (MOLIB_JWT_TTL_MINUTES). Hard ceiling 6 hours per the user's directive.
  • Carries minimal claims: sub (User.Id), iat, exp, jti. No PII.
  • Sent on every request as Authorization: Bearer ….
  • Held in SPA memory only — never localStorage / sessionStorage.
  • Refresh token — cryptographically random URL-safe string, 32 bytes.
  • TTL: 30 days (configurable, MOLIB_REFRESH_TTL_DAYS).
  • Persisted in the RefreshToken table; the raw token is delivered once to the client and only its SHA-256 hash is stored server-side.
  • Rotated on every use: each successful POST /sessions/refresh issues a new refresh token (new row, same FamilyId) and marks the previous row as used.
  • Delivered as a cookie: HttpOnly, Secure, SameSite=Strict, Path=/. The SPA never reads it. (Path scoping doesn't separate trust domains in a single-app origin, and HttpOnly + Secure + SameSite=Strict already block JS, plaintext, and cross-site delivery; tighter pathing forced every proxy layer to know about a rewrite.)

Endpoints

  • POST /sessions — sign-in. Request: { email, password }. Response: 200 { accessToken, expiresAt } + Set-Cookie: refresh=….
  • POST /sessions/refresh — exchange refresh token for a new access token + rotated refresh token. Request: empty body; cookie carries the refresh token. Response: 200 { accessToken, expiresAt } + new Set-Cookie.
  • DELETE /sessions — logout. Revoke the entire family the current refresh token belongs to (so all rotated descendants are invalidated). Clear the cookie.

Replay / theft detection (token families)

  • Each successful POST /sessions mints a new FamilyId. Every rotation copies the same FamilyId.
  • On POST /sessions/refresh:
  • If the presented token's row is active (UsedAt IS NULL AND RevokedAt IS NULL AND ExpiresAt > now()) → rotate normally.
  • If the row is already used (UsedAt IS NOT NULL) → treat as replay. Revoke the entire family (set RevokedAt on every row sharing that FamilyId) and return 401. This logs out a stolen-token attacker even if they got there first.
  • If the row is expired or revoked or unknown401.

Frontend strategy

The SPA needs exactly one authenticated-fetch wrapper. Pseudocode:

async function authedFetch(url, init) {
  let res = await fetch(url, withBearer(init, accessToken));
  if (res.status !== 401) return res;
  const refreshed = await fetch('/sessions/refresh', { method: 'POST', credentials: 'include' });
  if (!refreshed.ok) { redirectToSignIn(); throw new Error('reauth'); }
  ({ accessToken, expiresAt } = await refreshed.json());
  return fetch(url, withBearer(init, accessToken));
}
  • The refresh token cookie travels automatically because of credentials: 'include' (the cookie itself is Path=/ so it accompanies any same-origin request).
  • On tab open, if the SPA has no in-memory access token, it calls /sessions/refresh first and continues if successful.
  • A single in-flight refresh promise prevents thundering-herd on parallel 401s (chain new requests onto the same refresh).

Consequences

Positive:

  • Hot path (every API call) stays stateless — JWT signature check only, no DB.
  • One DB row per ~30 min of active session for the refresh path.
  • Real logout: deleting the family invalidates all descendants.
  • Free replay detection from rotation.
  • No tokens in localStorage / sessionStorage → reduced XSS blast radius.

Negative / things to watch:

  • Need a RefreshToken table and migration.
  • Server clock skew matters; keep all comparisons in UTC.
  • CSRF surface on POST /sessions/refresh. Mitigated by SameSite=Strict + path scoping; if we ever support cross-site flows, revisit (double-submit token, CORS allow-list).
  • A multi-tab SPA needs BroadcastChannel (or similar) to share the in-memory access token, otherwise each tab refreshes independently. Deferred until tab UX matters.
  • Symmetric HS256 is fine for one service. If Molib later splits into multiple services that verify tokens independently, revisit (RS256 + JWKS).

Triggers for revisiting

  • Multi-service architecture → consider RS256 + JWKS endpoint.
  • Public API consumers (not just our SPA) → may want long-lived API keys / OAuth client credentials, separate flow from this ADR.
  • Need for "active sessions" UI → already supported by the data model (one family per device); just exposure.