Skip to content

Table of Contents

ADR-0003: Auth — short-lived access JWT + rotating refresh tokens, with RBAC

  • Status: Accepted
  • Date: 2026-06-26
  • Deciders: Backend team

Context

The API is stateless and horizontally scaled behind a load balancer, so it must not hold per-instance session state. We need authentication that (a) lets any replica verify a caller without a shared session store on the hot path, (b) limits the blast radius of a leaked credential, © supports logout/revocation, and (d) gates each operation by the caller's tenant role.

Decision

Access tokens: short-lived JWT (HS256).

  • Claims: sub (user), org_id (tenant), role, scope=access, iss, aud, iat, exp, jti. TTL ~15 minutes.
  • Verified in-process by every replica (platform/auth.JWTIssuer) — no store lookup on the request path. The signing algorithm is pinned (HS256) to defeat alg confusion, and iss/aud are validated.

Refresh tokens: opaque, rotating, hashed at rest.

  • A 256-bit random value; only its SHA-256 hash is stored in refresh_tokens.
  • On /auth/refresh the presented token is revoked and a successor is issued (replaced_by links the chain) — single-use rotation.
  • Reuse detection: presenting an already-revoked refresh token implies theft; we revoke the user's entire token family and force re-login.
  • /auth/logout revokes the active token. TTL ~30 days.

Authorization: RBAC by permission, not by role.

  • Roles owner > admin > member; the role→permission matrix lives in exactly one place (domain.Role.Can). Handlers and services check permissions (project:create, member:manage, …), not roles directly.
  • Checks happen twice: a fast route-level requirePermission middleware and an authoritative check inside each use-case (defense in depth). Special cases (admins cannot modify owners; members may delete only their own tasks) live in the use-case.

Passwords: bcrypt, cost ≥ 12, timing-safe compare; the same vague invalid credentials error for unknown-user and wrong-password.

Consequences

Positive

  • Stateless verification scales horizontally; the DB is touched only on login/refresh/logout, not on every request.
  • Short access TTL caps exposure; rotation + reuse detection turns a stolen refresh token into a detectable, self-limiting event.
  • One RBAC source of truth is exhaustively unit-tested across the matrix.

Negative / costs

  • A leaked signing key forges access tokens until rotated — mitigated by a rotatable kid strategy (future) and key management via secrets, never code.
  • A valid access token cannot be revoked before its (short) expiry; we accept this for the 15-minute window rather than checking a denylist per request.

Neutral

  • org_id lives in the token, so switching active organization means issuing a token for another membership (a future /auth/switch endpoint).