Skip to Content
ArchitectureAuthRefresh tokens & key rotation

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_MS env override). Same claims as before: sub="Authentication", iss, aud, id. Sent as Authorization: Bearer ... on every authed request.
  • Refresh token — 32 bytes from SecureRandom, base64url-no-padding. Stored on the server as sha256(token + REFRESH_TOKEN_PEPPER) in refresh_sessions(token_hash unique). TTL 30 days. Sent in the request body to /auth/refresh and /auth/logout. Clients persist it in the same TokenStorage backend as the access token, under key "refresh_token".
  • refresh_sessions schema (Flyway V29__refresh_sessions_token_columns.sql):
    • id uuid PK
    • user_id → users.id
    • token_hash varchar(128) UNIQUE
    • expires_at timestamp
    • used_at timestamp NULL — set when this token is exchanged via /auth/refresh
    • revoked_at timestamp NULL — set on /auth/logout, password reset, or replay-detection chain revocation
    • rotated_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 stateResultHTTP
token_hash not foundInvalid401 AUTH_INVALID_CREDENTIALS
revoked_at != nullInvalid401 AUTH_INVALID_CREDENTIALS
used_at != null (replay)Reusedrevoke entire user’s active refresh chain401 AUTH_INVALID_CREDENTIALS
expires_at in the pastExpired401 AUTH_INVALID_CREDENTIALS
OtherwiseSuccess — 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:

  1. Acquire a per-HttpClient Mutex — coalesces concurrent 401s into one refresh attempt.
  2. If a successful refresh happened within the last 2 s, skip the network and just throw a retryable ClientRequestException (new token is already in TokenStorage).
  3. Otherwise call refreshHandler() — which in DI is koin.getOrNull<AuthService>()?.refresh() ?: false. The deferred Koin lookup breaks the cycle (HttpClient → AuthService → HttpClient) without resolving AuthService at HttpClient construction time.
  4. On true: throw retryable. Callers using ApiOutcome / runCatching (every service in the codebase today) see the throw and reissue with the new access token.
  5. On false: clear both tokens, broadcast SessionExpiredNotifier, 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

VariableRequiredDefaultNotes
JWT_SECRETyes in production"secret" in devServer fails to start in production without it.
JWT_EXPIRATION_MSno3_600_000 (60 min)Lower in tests to exercise refresh; raise only with eyes open.
REFRESH_TOKEN_PEPPERrecommendednullMixed 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/refresh retires the supplied token. Replay-detection (used_at != null on 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-flow slowEquals still 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 setBacking storeRefresh-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 / wasmJsMainBrowser localStorageXSS-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 kid header + multi-secret verifier. Today, rotating JWT_SECRET invalidates every access token in flight. The intended fix: add .withKeyId(currentKid) on issuance, accept a small map of kid → secret in the verifier, drive rotation by env. Refresh tokens are unaffected (they are not signed by JWT_SECRET), so secret rotation under this scheme only forces a single round of /auth/refresh from each client — no password re-prompt.
  • Per-device refresh sessions. Adding device_id to refresh_sessions would 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 manual addAuthHeaders (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.