Skip to main content

ADR-0003: Short-lived JWT access tokens + rotating opaque refresh tokens

Status: Accepted (retroactive) Date: 2026-07-24

Context

The ERP login needed session semantics that survive page reloads and long work days without forcing either extreme:

  • Server-side sessions would add sticky state to a backend that is otherwise stateless (CSRF handling, session store), and fit poorly with the token-bearing e2e/API clients.
  • Long-lived JWTs alone are unrevocable: a stolen token works until expiry, and payroll data is exactly the kind of thing you want to be able to cut off.

The constraint: keep request authentication stateless and cheap, but make the long-lived credential revocable and theft-detectable.

Decision

Two-token scheme, implemented in JwtTokenManager and RefreshTokenService:

  • Access token: a JWT signed with Algorithm.HMAC256, expiring after 15 minutes (accessTokenExpirationMinute: 15 in application.yml). Validated statelessly on every request by JwtAuthenticationFilter.
  • Refresh token: a 14-day opaque random token (not a JWT), stored only as a SHA-256 hash in the refresh_tokens table created by V75__auth_hardening.sql (token_hash, family_id, revoked_at, replaced_by_token_hash).
  • Rotation with family reuse-detection: every refresh issues a new token and revokes the presented one. If a token is presented a second time, the whole family is revoked on the assumption it was stolen (RefreshTokenService.rotate()revokeFamily()).
  • The frontend does silent refresh in the axios response interceptor (frontend/src/api/client.ts); the customer portal deliberately does not use this scheme (24-hour JWT, no refresh — see security/authentication.md).

Consequences

Positive

  • Per-request auth stays stateless — no session store, no DB lookup on normal API calls.
  • Logout and compromise response are real: revoking a family kills the session within at most one access-token lifetime.
  • A database dump alone cannot be replayed: only hashes are stored.

Negative

  • Access tokens are not individually revocable — a stolen access token works for up to 15 minutes no matter what the server does.
  • Client complexity is real: because rotation makes refresh tokens single-use, concurrent 401s must share one in-flight refresh promise (client.ts documents that two parallel refreshes look like a replay attack and log the user out). This bug class has already bitten the e2e suite.
  • Every refresh is a DB round-trip (hash lookup + insert + update), accepted because refreshes happen at most every ~15 minutes per user.
  • Two auth code paths (ERP vs portal) must be reasoned about separately.

References