Decisions

Session lifetimes per OAuth client

Why refresh-token lifetimes move from one global setting to a policy per application, how a first-party native app stays signed in for months, and what users and admins get to end those sessions.

Status: accepted (2026-11-05), shipped in @frigear-nu/oauth-server 0.5. Scope: @frigear-nu/oauth-server, @frigear-nu/auth (one hook), @frigear-nu/users (fires it), @frigear-nu/account (a page section), their docs. Drafted from personal-finance, whose iOS app is the first consumer. How it works today: Session lifetimes.

Context

OAuth Server issues refresh tokens with one lifetime for every application: refreshTokenTtl, default 30 days (packages/oauth-server/src/module.ts, oauthServer.refreshTokenTtl). createRefreshToken stamps expiresAt = now + refreshTokenTtl (runtime/server/utils/oauth-grants.ts), and every refresh rotates the token into a new row with a fresh 30 days. So today the window is a sliding inactivity timeout: a user who opens the app once a month never signs in again, and one who is away for 31 days always does.

That one number cannot fit every kind of client the server has:

ClientWhere the refresh token livesWhat users expect
First-party native app (Capacitor, com.example.app:/auth/callback)Keychain / KeystoreSigned in until they sign out, like Reddit, Instagram or Gmail
Third-party web app (confidential, server-side)The app's serverWeeks to months
Browser SPA (public, https redirect only)localStorage, readable by any XSSHours to a day (Microsoft caps SPAs at 24 h)
MCP / CLI / integrationsA config file or a vaultMonths, revoked by hand

Raising the global setting to six months gives every SPA a six-month credential in localStorage. Keeping it at 30 days signs mobile users out after a holiday. What large providers do instead is a lifetime per client plus an inactivity and an absolute limit (Auth0 and Okta expose both knobs; Google expires an unused refresh token after about six months), and they lean on revocation, not expiry, to end long sessions.

What we already have, and keep: access tokens stay short (accessTokenTtl, 15 minutes); refresh tokens rotate, and reusing a rotated one revokes its whole family (rotateRefreshToken); an admin can revoke every grant of an application (POST /api/admin/applications/:id/revoke). What is missing: a lifetime per application, a hard cap, and a way for a user to end their own sessions or have them ended when their password changes.

Decision

1. A refresh-token policy per application

Two limits, both in seconds:

  • idleTtl: how long a refresh token lives unused. Every rotation starts a new window (today's behaviour).
  • maxAge: how long a sign-in lives in total, counted from the original authTime, which the refresh token row already carries across rotations. null = no cap.

A new row gets expiresAt = min(now + idleTtl, authTime + maxAge). rotateRefreshToken already refuses an expired row, so nothing else in the token endpoint changes. Because the policy is read when a token is created, a changed policy reaches existing sessions on their next refresh; no backfill.

Where the policy comes from, first match wins:

  1. The application: two nullable columns on oauth_applications, refreshIdleTtl and refreshMaxAge (null = inherit). Editable in the admin slideover as Session length: Default, 1 day, 7 days, 30 days, 90 days, 6 months, 1 year, plus an optional Sign in again after cap.
  2. A configured client: oauthServer.clients[].refreshToken: { idleTtl?, maxAge? }, managed by config like its other fields (409 managed_by_config on edit).
  3. The global defaults: oauthServer.refreshToken: { idleTtl: 30 days, maxAge: null }. refreshTokenTtl stays as a deprecated alias for refreshToken.idleTtl, so existing apps behave exactly as today.
nuxt.config.ts
oauthServer: {
  refreshToken: { idleTtl: 60 * 60 * 24 * 30 }, // everyone else: 30 days idle
  clients: [{
    id: 'eu.hareland.bills',
    name: 'Household bills',
    redirectUris: ['eu.hareland.bills:/auth/callback'],
    refreshToken: { idleTtl: 60 * 60 * 24 * 180 }, // the iOS app: 6 months idle, no hard cap
  }],
}

2. Guardrails by client kind

A long lifetime is only safe where the token is stored safely. The server infers the kind from what it already knows: a native application is a public application whose redirect URIs are all custom-scheme or loopback; a browser application is a public application with an https redirect URI.

  • Upper bound for everyone: idleTtl and maxAge at most 2 years. No "never expires": a forgotten device should eventually stop working by itself.
  • Browser clients: above 7 days idle, the admin form shows a warning and the config validator logs one, naming XSS. A deliberate allowLongBrowserSessions: true on the client silences it.
  • Native and confidential clients: no warning.

3. Ending long sessions

Long sessions need ways to end them before they expire. All three use the existing revokeFamily and fire the existing oauth:token:revoked hook.

  • Users see and end their own sessions. GET /api/oauth-server/me/sessions lists the caller's live refresh-token families grouped per application, as the large identity providers do: application name and icon, first signed in (authTime), last active (the newest row's createdAt, since each refresh writes a row) and the number of live families. (It first listed one entry per family, which showed an application that re-authorized on the same device several times, with nothing to tell the entries apart.) DELETE /api/oauth-server/me/sessions/:id (the application id) revokes that application's families, and DELETE /api/oauth-server/me/sessions revokes them all (the caller is a browser session, never one of the families). The routes follow the module's URL convention (/api/<module>/me/...) and are session only, like everything that changes how a user signs in. @frigear-nu/account shows them as Signed-in apps on the Security page, when OAuth Server is installed.
  • Credential changes end every session. Auth defines a new Nitro hook, auth:credentials:changed ({ userId, reason: 'password' | 'password-reset' | 'email' }), fired through notifyAuthCredentialsChanged after a password reset (Auth) and a password change, an email change and an email revert (Users, which owns those routes). OAuth Server listens and revokes all of that user's families. An option revokeOnCredentialChange (default true) turns it off.
  • Admins keep revoke-all per application. A per-user revoke on the admin user page is left for a later change; revokeUserGrants(userId) already does it from code.

A device label ("iPhone, Safari") is left out of this change. The token request carries no reliable device name, and a User-Agent string from a webview says little. A later change can add an optional device_name parameter that native clients send at the code exchange.

4. Clients

Auth in client mode needs nothing: it already refreshes on demand and signs out when a refresh fails. The token response gains the non-standard but common refresh_token_expires_in (seconds until the new row's expiresAt), which clients may use to warn before a forced sign-in; Auth ignores it for now.

Consequences

  • Migration: two nullable integer columns on oauth_applications (sqlite and postgresql schemas). Every host generates and commits a migration, as with isListed in 0.4.1. No data changes: null inherits the global policy, which defaults to today's 30 days idle.
  • Release: @frigear-nu/oauth-server 0.5.0 (schema and options), @frigear-nu/auth minor (the hook), @frigear-nu/users (fires it) and @frigear-nu/account minor (Signed-in apps). Each works without the others: without the hook nothing revokes on a password change, without Account the API is still there.
  • Security: a stolen refresh token from a native app now lives up to idleTtl if the thief keeps refreshing it. Rotation still detects a second user of the same token and kills the family, and the user can end it from Security. Access tokens stay at 15 minutes, so resource servers that cannot revoke are unaffected.
  • Load: none. The policy is a read on the application row, which the token endpoint already loads.
  • A policy that shrinks: a sign-in already past a new, shorter maxAge is refused at its next refresh, even if its current token was issued a minute ago, rather than handed a token that is born expired.

Alternatives considered

  • Raise the global lifetime. One line, but it gives browser SPAs the same six months. Rejected.
  • Lifetime by clientType only (public / confidential). Public covers both native apps (safe storage) and SPAs (localStorage), the two extremes. Rejected in favour of a per-application value with guardrails by kind.
  • Non-expiring tokens for first-party apps. What large consumer apps effectively do, but they have device management, anomaly detection and support staff behind it. A 2-year ceiling with sliding renewal gives the same experience for anyone who opens the app at all.
  • Re-authentication with biometrics instead. Complementary, not a replacement. It belongs in @frigear-nu/mobile as an app lock and does not change server lifetimes.

Tests

  • createRefreshToken uses application, then configured client, then global policy; maxAge caps a sliding window.
  • Rotation after a policy change gives the new row the new lifetime.
  • A token past authTime + maxAge is refused even if it was rotated a minute ago.
  • refreshTokenTtl alone still produces today's 30-day behaviour.
  • Browser-kind client above 7 days warns; allowLongBrowserSessions silences it; values above 2 years are refused.
  • Sessions API lists only the caller's families, revokes one / all others, and fires oauth:token:revoked.
  • auth:credentials:changed revokes every family of that user and none of anyone else's.
Copyright © 2026