Auth: refresh tokens & key rotation
Status: implemented. As of 2026-06-21 the Ktor server issues a short-lived HMAC-256 access JWT plus a single-use rotating opaque refresh token on /auth/login; clients exchange refresh tokens at /auth/refresh and revoke them at /auth/logout. The wider auth subsystem (login flow, password reset, token storage per platform, session expiry) lives at Auth subsystem; see Onboarding journey for the user-facing signup-to-dashboard path.
JWT kid + multi-secret verifier for graceful JWT_SECRET rotation is not implemented yet — a deliberate rotation today still mass-logs-out every active access token. That follow-up is sketched at the bottom of this page.
Shape
- Access token — HMAC-256 JWT, default TTL 60 min (
JWT_EXPIRATION_MSenv override). Same claims as before:sub="Authentication",iss,aud,id. Sent asAuthorization: Bearer ...on every authed request. - Refresh token — 32 bytes from
SecureRandom, base64url-no-padding. Stored on the server assha256(token + REFRESH_TOKEN_PEPPER)inrefresh_sessions(token_hash unique). TTL 30 days. Sent in the request body to/auth/refreshand/auth/logout. Clients persist it in the sameTokenStoragebackend as the access token, under key"refresh_token". refresh_sessionsschema (FlywayV29__refresh_sessions_token_columns.sql):id uuid PKuser_id → users.idtoken_hash varchar(128) UNIQUEexpires_at timestampused_at timestamp NULL— set when this token is exchanged via/auth/refreshrevoked_at timestamp NULL— set on/auth/logout, password reset, or replay-detection chain revocationrotated_from_id uuid NULL → refresh_sessions.id— points at the row this token replaced (audit trail)created_at timestamp
Endpoints
All routes below sit under the /api/v1/ version prefix (e.g. the full URL is https://host/api/v1/auth/login). The shared client’s isPublicAuthPath matches the suffix only.
POST /api/v1/auth/login (public, gated by SIGN_UP flag on register only)
Returns both tokens. Issuance lives in AuthRouting.kt — after the credential check, the handler calls refreshTokenService.issue(userId) and inlines the result in AuthResponse.
// 200 OK
{ "token": "eyJ...", "refreshToken": "Zn8X...", "tier": "FREE" }POST /api/v1/auth/refresh (public, rate-limited refresh: 30 / 10 min per IP)
Request:
{ "refreshToken": "Zn8X..." }Server behavior in RefreshTokenService.rotate:
| Stored state | Result | HTTP |
|---|---|---|
token_hash not found | Invalid | 401 AUTH_INVALID_CREDENTIALS |
revoked_at != null | Invalid | 401 AUTH_INVALID_CREDENTIALS |
used_at != null (replay) | Reused — revoke entire user’s active refresh chain | 401 AUTH_INVALID_CREDENTIALS |
expires_at in the past | Expired | 401 AUTH_INVALID_CREDENTIALS |
| Otherwise | Success — mark used_at = now, insert new row with rotated_from_id = old.id, return fresh (access JWT, refresh token) | 200 same shape as login |
The four failure modes return an identical body so a scraping attacker can’t tell why their token failed.
POST /api/v1/auth/logout (authenticated)
{ "refreshToken": "Zn8X..." }Sets revoked_at = now on the matching active row. Returns 204 No Content. Best-effort: the client clears local storage regardless of network outcome (AuthService.logout wraps the call in runCatching).
POST /api/v1/auth/reset-password (existing)
Already revokes every active refresh row for the user via RefreshSessions.update({ userId eq … and revokedAt.isNull() }) { revokedAt = now }. The rotation chain becomes uniformly dead — replay-detection becomes a no-op because subsequent refresh attempts hit the revokedAt != null branch.
Client wiring
HttpClientConfig.installCommonPlugins takes a refreshHandler: suspend () -> Boolean. On a 401 from a non-public path:
- Acquire a per-
HttpClientMutex— coalesces concurrent 401s into one refresh attempt. - If a successful refresh happened within the last 2 s, skip the network and just throw a retryable
ClientRequestException(new token is already inTokenStorage). - Otherwise call
refreshHandler()— which in DI iskoin.getOrNull<AuthService>()?.refresh() ?: false. The deferred Koin lookup breaks the cycle (HttpClient → AuthService → HttpClient) without resolving AuthService at HttpClient construction time. - On
true: throw retryable. Callers usingApiOutcome/runCatching(every service in the codebase today) see the throw and reissue with the new access token. - On
false: clear both tokens, broadcastSessionExpiredNotifier, throw — VMs collected on the notifier route to the login screen.
/auth/refresh is in isPublicAuthPath (AuthPlugin.kt) — the refresh token travels in the body, no Bearer header. /auth/logout is not public; it requires the access token.
Env vars
| Variable | Required | Default | Notes |
|---|---|---|---|
JWT_SECRET | yes in production | "secret" in dev | Server fails to start in production without it. |
JWT_EXPIRATION_MS | no | 3_600_000 (60 min) | Lower in tests to exercise refresh; raise only with eyes open. |
REFRESH_TOKEN_PEPPER | recommended | null | Mixed into the SHA-256 of every refresh-token hash. Without it, a DB leak gives an attacker hashes that are still useful in an offline rainbow / dictionary attack against short tokens — although our 32-byte SecureRandom inputs make that impractical, the pepper is cheap insurance. Do not share with PASSWORD_RESET_PEPPER — separate blast radius. |
Security properties
- Single-use refresh tokens — each
/auth/refreshretires the supplied token. Replay-detection (used_at != nullon lookup) triggers chain-wide revocation, so a stolen-then-replayed refresh logs both legit client and attacker out. - Uniform 401 on
/auth/refresh— the four failure modes (unknown, revoked, used, expired) are indistinguishable to the caller. Attackers cannot enumerate token state. - Hash-at-rest — tokens are only ever stored as
sha256(token + pepper). A DB-only leak does not yield usable tokens unless the attacker also exfiltrates the pepper. - Constant-time compare not needed for refresh lookup — we look up by
token_hash(unique index, server-determined hash) rather than comparing token bytes; the index access is hash-table style. The PIN-flowslowEqualsstill applies to PIN verification. - Rate limited — 30 / 10 min per remoteHost. Brute-forcing a 32-byte random token via rate-limited refresh is computationally absurd, but the limit also caps storm patterns from a misbehaving client.
- Password reset = global refresh revoke — already in
PasswordResetService.resetPassword.
Client storage caveats
| Source set | Backing store | Refresh-token risk |
|---|---|---|
nonJsMain (Android, iOS, desktop) | DataStore<Preferences> | App-sandboxed; on Android encrypted at rest via DataStore + filesystem perms; on iOS the protected app container; on desktop OS file permissions. |
jsMain / wasmJsMain | Browser localStorage | XSS-exposed — the same posture as the access token today. Accepted parity-of-risk for the dev/preview JS targets. The production web client (when there is one) should migrate to httpOnly cookies with CSRF protection. |
Out of scope (future work)
- JWT
kidheader + multi-secret verifier. Today, rotatingJWT_SECRETinvalidates every access token in flight. The intended fix: add.withKeyId(currentKid)on issuance, accept a small map ofkid → secretin the verifier, drive rotation by env. Refresh tokens are unaffected (they are not signed byJWT_SECRET), so secret rotation under this scheme only forces a single round of/auth/refreshfrom each client — no password re-prompt. - Per-device refresh sessions. Adding
device_idtorefresh_sessionswould let “log out other devices” become a single SQL update. The current schema can absorb that column without a breaking change. Auth { bearer { loadTokens / refreshTokens } }plugin migration. Today every call site uses manualaddAuthHeaders(AuthPlugin.kt); migrating to Ktor’s built-in bearer plugin would auto-retry the original request and remove the manual injector. Deferred to keep this PR small.- Web httpOnly-cookie refresh path. Required when a production web client lands.
Related code
AuthRouting.kt—/auth/login,/auth/refresh,/auth/logoutRefreshTokenService.kt— issue / rotate / revokeRefreshSession.kt— Exposed table + entityTokenUtil.kt— opaque-token generation + SHA-256 base64url +slowEqualsJWTUtils.kt— access JWT (60 min default)HttpClientConfig.kt— 401 → refresh → retry plumbingAuthService.kt(shared) —refresh(),logout()client sideTokenStorage.kt—save/get/clear RefreshTokeninterface