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: 15inapplication.yml). Validated statelessly on every request byJwtAuthenticationFilter. - Refresh token: a 14-day opaque random token (not a JWT), stored only
as a SHA-256 hash in the
refresh_tokenstable created byV75__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.tsdocuments 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
../../backend/src/main/java/com/motorph/payroll/security/jwt/JwtTokenManager.java../../backend/src/main/java/com/motorph/payroll/security/service/RefreshTokenService.java../../backend/src/main/resources/db/migration/V75__auth_hardening.sql../../frontend/src/api/client.ts— single-flight silent refresh../security/authentication.md