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¶
- 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.
- 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.
- 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
RefreshTokentable. 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
RefreshTokentable; 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/refreshissues a new refresh token (new row, sameFamilyId) 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, andHttpOnly+Secure+SameSite=Strictalready 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 }+ newSet-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 /sessionsmints a newFamilyId. Every rotation copies the sameFamilyId. - 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 (setRevokedAton every row sharing thatFamilyId) and return401. This logs out a stolen-token attacker even if they got there first. - If the row is expired or revoked or unknown →
401.
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 isPath=/so it accompanies any same-origin request). - On tab open, if the SPA has no in-memory access token, it calls
/sessions/refreshfirst 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
RefreshTokentable and migration. - Server clock skew matters; keep all comparisons in UTC.
- CSRF surface on
POST /sessions/refresh. Mitigated bySameSite=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.