Skip to content

How NovaVMS sessions work

Every signed-in NovaVMS user holds two tokens: a short-lived access token (30 minutes) that authenticates API calls, and a refresh token (7 days by default, or 30 days when Remember me is checked) that mints new access tokens when the current one expires. Admins can revoke any active session in real time. A password change revokes every session of the affected user across every device (per R2 and D48).

Why this works this way

A single long-lived token is the simplest design and the one we rejected. It gives attackers a token with a 30-day usable lifetime if they ever exfiltrate it. A single short-lived token, on the other hand, makes users sign in every 30 minutes. The two-token pattern resolves this by separating “prove you are still allowed to hold a session” from “make this API call right now.”

The access token is a JWT signed by the cloud backend’s key. It carries the user’s ID, role, org ID, the session ID, and (when a platform admin is impersonating) an impersonation_id claim — plus an expiry 30 minutes in the future. Every API request carries it in the Authorization: Bearer header (for mobile and API clients) or in memory as a Bearer header for web clients (web sends the JWT as a Bearer header on every API call; only the refresh token rides as an HttpOnly cookie). The backend validates the signature at a middleware layer — handlers never see an unauthenticated request. When the token expires, the client refreshes.

The refresh token is a random 256-bit secret stored server-side in the sessions table. A refresh request trades the refresh token for a new access token without prompting the user. Whether the refresh token itself rotates depends on the client type (D48):

  • Web clients (cookie-based refresh) keep the same refresh token across every refresh until it expires. The cookie is HttpOnly + Secure + SameSite=Lax.
  • Mobile and API clients (Bearer refresh) rotate the refresh token on every successful refresh — the old one is invalidated and a new one returned. This narrows the theft window for non-cookie clients.

A reused refresh token after rotation fails with 401. The “detect-and-revoke session family” pattern (cascade-revoke every descendant of a reused token) is a future enhancement; today, a reused token just stops working.

Three points can kill a session.

  • The user signs out. The access token is discarded client-side; the session row is marked revoked.
  • An admin revokes the session from Admin → Active sessions. The session row is marked revoked; the next refresh attempt fails and the user is forced back to the sign-in page within 30 minutes (the access-token TTL). The currently issued access token keeps working until it naturally expires — the JWT middleware validates signature + expiry only, not session-revocation state on every request.
  • The user’s password changes. When the user changes their own password, every session row for that user is revoked (hard-logout path). Admin-initiated password reset does NOT currently revoke sessions — cloud/internal/accounts/user_service.go:285 calls only ResetUserPassword, and cloud/internal/accounts/user_repository.go:193 only updates the users table without touching sessions. Sessions issued before the reset stay live until the refresh token expires (or the user is signed out manually). Tracked as GAP-6-07; treat admin-reset followed by explicit session revocation in Admin → Active sessions as the operative hard-logout flow until the code is updated.

Binding is by User-Agent, not IP, which is the subject of its own trade-off and its own concept page — see Why refresh tokens are bound to User-Agent, not IP.

D48 covers mobile clients. Web clients keep refresh tokens in HttpOnly cookies that JavaScript cannot read, which is the correct choice for browser XSS resistance. Mobile clients and API consumers cannot use browser cookies, so they receive the refresh token in the response body and the server marks the session with client_type = android | ios | api. The rest of the model is identical.

We landed here rather than shipping something closer to OAuth 2.1 device-flow because the v1 customer is a self-hoster, not a public-API consumer. A third-party integration would use service-account API keys (D83) rather than a user OAuth flow.

What this means for you

  • A user who is not touching the product sees no interruption for up to 7 days (or 30 days if they checked Remember me at sign-in). After that, the refresh token expires and they sign in again.
  • The “active sessions” view shows one row per device (or per browser), not one row per access token. An operator signed in on a laptop and a phone counts as two sessions.
  • Revoking a session kicks the user within 30 minutes. It is not instantaneous, because the currently issued access token is valid until it expires on its own. The session row is marked revoked the moment you click Revoke, so refreshes fail immediately, but the JWT middleware does not re-check the row on each request.
  • Changing a user’s password forces a logout on the next refresh, because every session row for that user is revoked. The unexpired access token can still pass JWTAuth for up to 30 minutes, but the user cannot refresh and any 401 from a backend operation sends them to /login.
  • Admins can revoke only within their own org, per the org-scoping rule — see Why every resource belongs to exactly one org.
  • Service-account API keys are not sessions. They do not appear in the active-sessions view and are not revoked by password changes; they have their own lifecycle under Settings → Service Accounts.

Where to go next