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:

StrategyStateless?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:

  1. 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.

  2. Refresh token — 64-byte cryptographically random value (not a JWT), hashed with SHA-256, stored in the refresh_tokens database table. Sent to the client as an HttpOnly, Secure, SameSite=Strict cookie scoped to Path=/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=Strict means 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_tokens table to be cleaned up periodically (no automated cleanup job is included)
  • SameSite=Strict means the cookie is not sent in cross-origin iframes or redirects — acceptable for this SPA architecture
  • The Secure flag means the cookie is only sent over HTTPS; local development requires either HTTP exemptions or a reverse proxy with TLS