ADR-004: JWT Access Tokens + HttpOnly Cookie Refresh Tokens
Status: Accepted
Context
The application needs an authentication strategy that is:
- Stateless on the hot path — no database lookup on every authenticated request
- Revocable — compromised sessions can be invalidated
- Secure against XSS — the long-lived credential must not be accessible to JavaScript
- Secure against CSRF — the credential must not be automatically sent cross-origin
- Invite-based — new users are created by admins, not self-registering
Options considered:
| Strategy | Stateless? | Revocable? | XSS-safe? | CSRF-safe? |
|---|---|---|---|---|
| Session cookies (server) | ❌ | ✅ | ✅ | ⚠️ needs token |
| Long-lived JWT in localStorage | ✅ | ❌ | ❌ | ✅ |
| Short JWT + HttpOnly refresh cookie | ✅ | ✅ per-token | ✅ | ✅ |
Decision
Use a two-token strategy:
-
Access token — short-lived JWT (15 minutes), signed with HS256, stored in Redux state (in-memory / localStorage). Sent as
Authorization: Bearer <token>on every API request. -
Refresh token — 64-byte cryptographically random value (not a JWT), hashed with SHA-256, stored in the
refresh_tokensdatabase table. Sent to the client as an HttpOnly, Secure, SameSite=Strict cookie scoped toPath=/api/auth. Never accessible to JavaScript.
Access token payload (AppJwtPayload):
{
sub: string; // user UUID
email: string;
name: string;
role: 'admin' | 'user';
tokenType: 'auth' | 'signup';
iat: number;
exp: number; // 15 minutes from iat
}Refresh token cookie attributes:
Set-Cookie: refresh_token=<value>; HttpOnly; Secure; SameSite=Strict; Path=/api/auth; Max-Age=604800
Rotation on every use — the existing refresh token record is deleted and a new one is inserted on every successful POST /api/auth/refresh. A reused (already-consumed) token returns 401.
Invite flow — an authType: 'signup' JWT (24-hour expiry) is embedded in the invite link. The signupTokenMiddleware validates it for POST /api/auth/set-password and rejects it everywhere else.
Rate limiting — login and refresh endpoints are limited to 10 requests per minute per IP (in-memory; DISABLE_RATE_LIMIT=true bypasses for E2E tests).
Consequences
Positive
- Access tokens are validated by signature only (no DB lookup) — low latency on every request
- The refresh token is never readable by JavaScript — XSS cannot steal it
SameSite=Strictmeans the refresh cookie is not sent on cross-origin requests — CSRF is not possible- Token rotation limits the blast radius of a stolen refresh token to a single use
- RBAC roles are embedded in the JWT — middleware can check roles without a DB query
Negative / Risks
- Access tokens cannot be revoked mid-flight (15-minute window) — if a user’s role changes, the change takes up to 15 minutes to propagate; this is acceptable for the boilerplate’s use case
- Refresh tokens are stored in the database — this adds a DB write per login and per refresh, and requires the
refresh_tokenstable to be cleaned up periodically (no automated cleanup job is included) SameSite=Strictmeans the cookie is not sent in cross-origin iframes or redirects — acceptable for this SPA architecture- The
Secureflag means the cookie is only sent over HTTPS; local development requires either HTTP exemptions or a reverse proxy with TLS