ADR-002 · JWT access tokens + refresh tokens
Status: Accepted
Context
The API serves three clients — two Next.js web apps and an Expo mobile app — and must stay stateless enough to run behind a load balancer without sticky sessions, while still supporting logout, session revocation, and short-lived credentials for security. Mobile clients also need to stay logged in for long stretches without re-entering a password.
Decision
Use short-lived JWT access tokens plus long-lived refresh tokens:
- Access token — 15 minutes, sent as
Authorization: Beareron every request. Carriesuser_id,tenant_id,email,is_platform_admin. - Refresh token — 30 days, stored in an HTTP-only cookie (web) or SecureStorage (mobile), exchanged at
POST /api/auth/refresh(viaX-Refresh-Token) for a fresh access token. - Sessions table tracks issued tokens (hash + expiry) so logout/password-change can revoke them — giving stateful control on top of stateless tokens.
- Passwords hashed with bcrypt (cost ≥ 12); auth endpoints rate-limited.
Consequences
Positive
- Requests verify cheaply (signature check), no session store lookup on the hot path.
- Short access-token lifetime limits the blast radius of a leaked token.
- The sessions table restores revocation, so “log out everywhere” and “revoke on password change” work despite stateless tokens.
Negative / cost
- Two-token flow adds client complexity (refresh handling, retry-on-401).
- Rotating
JWT_SECRETforces all users to re-login — must be announced. - Revocation depends on the sessions table being checked/maintained; expired sessions need a scheduled cleanup.
Alternatives considered
- Server-side sessions only — simpler revocation, but a session lookup per request and sticky-session or shared-store overhead across three clients.
- Long-lived access tokens, no refresh — simpler, but a leaked token stays valid too long.