Sessions

How sessions get issued, signed, and verified — the HMAC-SHA256 cookie payload format. For cookie attributes see Cookie security.

A Voltro session is a signed cookie. No server-side session store, no Redis dependency. The cookie is the session.

Format

voltro:session=<base64url(payload)>.<base64url(signature)>

Where:

  • payload is JSON { v, subject, exp, iat, kid? }, e.g. { "v": 2, "subject": { "type": "user", "id": "user-id", "tenantId": "tenant-id" }, "exp": 1736294400, "iat": 1735689600 }. v is the payload format version; iat drives the sliding-window renewal below; kid is the (non-secret) label of the signing key, stamped when signing with a keyed secret set.
  • signature is HMAC-SHA256(payload, AUTH_SECRET)

Why HMAC, not JWT?

JWTs come with the alg: 'none' attack and a long history of header confusion. The framework's format is intentionally simpler: HMAC-SHA256, fixed algorithm, no header.

subject in the payload is a SubjectIdentity — the Subject union with scopes omitted at the schema level. A session cookie says who the caller is. What they may do is resolved on every request by auth.resolveScopes.

This is not a style choice, it is the whole lifetime argument. Identity is settled once at sign-in and never changes. Authority changes the moment an admin removes a role — and a value signed into a 7-day cookie is frozen for 7 days, longer if the sliding renewal keeps re-signing it. Embedding scopes meant that narrowing someone's role had no effect on the session they already held: you could sign them out entirely, but you could not take one permission away.

Two consequences to know:

  • issueSession / signSession throw when handed a subject that carries scopes. They do not strip them. A silently dropped scope is an authorization change with no error, no log line and no diff — you find it later, as "permissions randomly stopped working".
  • readSession / verifySession return a SubjectIdentity. The absent field is the guarantee: nothing downstream can read authority out of a cookie, because the value it gets back has nowhere to keep it.

If you build a Subject yourself at login — a custom sign-in route, an SSO onLogin — mint the session from the identity and move the permissions into the resolver:

// before: authority frozen into the cookie for a week
issueSession({ ...identity, scopes: permissionsFor(user) }, secret)

// after: identity in the cookie, authority resolved per request
issueSession(identity, secret)

Payload versioning — what happens to live sessions

v is required and pinned to the version this build mints. A payload without it, or with an older one, fails to decode — it does not fall back to a lenient read. A cookie from the previous format asserts authority, and honouring that assertion is exactly the defect being removed; authenticating someone under a contract the server no longer holds is worse than asking them to sign in.

So a framework upgrade that bumps the payload version ends every live session. Plan it like a secret rotation: users see the sign-in screen once. Their sessions rows are untouched.

Issuing

import { issueSession } from '@voltro/plugin-auth'

// Positional args: (subject, secret, options?). Returns { value, setCookie }.
const { value, setCookie } = issueSession(
  subject,                            // an identity, e.g. subjectFromUser(user) — no scopes
  AUTH_CONFIG.secret,
  {
    ttlSeconds: 60 * 60 * 24 * 7,     // 7 days (default if omitted)
    domain:     '.your-product.com',  // flat on options, not nested
    secure:     true,                 // defaults to true
  },
)

// Set on the response
res.setHeader('set-cookie', setCookie)

setCookie is the full Set-Cookie string — voltro:session=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800. value is the raw signed cookie value (<payload>.<sig>) if you need it directly. IssueSessionOptions carries only ttlSeconds, domain, and secure — HttpOnly and SameSite=lax are hardcoded in the builder and not configurable.

Reading

import { readSession } from '@voltro/plugin-auth'

const subject = readSession(req.headers.cookie, AUTH_CONFIG.secret)
// → { type: 'user', id, tenantId } | null   — a SubjectIdentity: no `scopes`

Returns null for:

  • Missing cookie
  • Tampered payload (signature mismatch)
  • Expired session (exp < now)
  • A payload from a superseded format version (see above)

A client without a cookie jar — React Native, a desktop shell — presents the same signed value on the x-voltro-session header (SESSION_HEADER_NAME from @voltro/protocol), attached to every rpc frame through the api's headers. The built-in strategy, the /auth/* routes and the store-less default in voltro dev all read header or cookie, header first, through one reader:

import { readSessionFromHeaders } from '@voltro/plugin-auth'

const result = readSessionFromHeaders(req.headers, AUTH_CONFIG.secret)
// → { subject, kid, renew, exp, iat } | null — from the header when set, else the cookie

It is a dedicated header rather than Authorization: Bearer, because that header already carries api keys, service tokens and IdP tokens for other strategies, and a session value that fails to verify must fail the chain without shutting those out. How a native client obtains the value: native login.

Clearing

import { clearSessionCookie } from '@voltro/plugin-auth'

res.setHeader('set-cookie', clearSessionCookie({ domain: '.your-product.com', secure: true }))
// voltro:session=; HttpOnly; Secure; …; Max-Age=0

clearSessionCookie takes the same IssueSessionOptions shape (ttlSeconds ignored; domain / secure honoured).

Secret rotation

Zero-downtime rotation is env-driven and applies to every verify path — the framework's rpc auth chain, the plugin's /auth/* routes, and readSession itself:

  1. Set the NEW secret as VOLTRO_SESSION_SECRET (current).
  2. Move the OLD secret to VOLTRO_SESSION_SECRET_PREVIOUS. Verification now tries current first, then previous — no live session is invalidated. (VOLTRO_SESSION_KID / VOLTRO_SESSION_KID_PREVIOUS are optional non-secret labels; they default to k0 / k-previous.)
  3. Leave the window open for one max session lifetime, then drop the _PREVIOUS var. Cookies still signed with the old key stop verifying at that point — but they rarely exist by then, because any previous-key cookie that hits an authenticated /auth/* route (or GET /auth/session) is re-issued under the current key in the response.

Under the hood: resolveSessionSecrets() builds the { current, previous? } keyed set from those env vars; signSession always signs with current (stamping its kid); verifySessionKeyed tries current then previous. The plugin's AuthConfig also accepts an explicit secrets: SessionSecrets when you'd rather not use env vars. The single-secret resolveSessionSecret() still exists for apps that don't rotate.

The session cookie ships HttpOnly, SameSite=lax, and Path=/ hardcoded; Secure, Domain, and Max-Age (from ttlSeconds) come from the options you pass issueSession. The full attribute checklist — and why SameSite=lax is the right default for cross-origin sign-in — lives on the Cookie security page.

Multi-instance — no shared store needed

Because the session is the cookie + signature is deterministic from (payload, secret), every api instance verifies independently. Scale to N replicas without a Redis cache; rolling deploys don't invalidate sessions.

For centralised revocation, the plugin writes a sessions row on every sign-in and exposes handleListSessions / handleRevokeSession / handleRevokeAllOtherSessions (mounted at GET /auth/sessions + POST /auth/sessions/revoke + /sessions/revoke-others). This is the device-management + "sign out other devices" surface — a stolen session can be killed without rotating the secret.

Revocation is enforced at request time: on every verify, the cookie's server-side session id (metadata.sessionId) is checked against the sessions table through a small in-process TTL cache (default 30 seconds, tune via the plugin's sessionRevocation.ttlMs). Be honest about the window: a revocation is instant on the process that performed it (its cache entry is invalidated inline — sign-out kills the cookie immediately there) and takes effect within the cache window on every other replica. POST /auth/sign-out deletes the caller's session row, and a password-reset confirm revokes all of the user's sessions — in both cases a retained copy of the cookie stops authenticating within that window, days before its exp. The framework's rpc auth chain enforces the same check: authRoutesPlugin carries a pre-wired session strategy (sharing the same cache) that voltro dev / voltro serve slot into the chain automatically. A cookie minted by hand via issueSession — an IdP callback signing someone in — carries no sessionId and stays purely stateless: a stolen copy authenticates until it expires, and signing out only clears the browser. Close that with a registered session:

import { dataStoreSessionRegistry, issueRegisteredSession, voltroPasswordStrategy } from '@voltro/plugin-auth'
import { sessionsTable } from '@voltro/plugin-auth/schema'   // register it in your databaseHandle

// in the callback, instead of issueSession
const issued = await issueRegisteredSession(dataStoreSessionRegistry(store), subject, secret, { ipAddress, userAgent })
// issued.value / issued.setCookie as before; issued.sessionId is the row

// carried by a plugin as `auth.sessionStrategy`, so the rpc chain checks every request
voltroPasswordStrategy({ sessionRegistry: dataStoreSessionRegistry, revocation: { ttlMs: 0 }, requireSessionId: true })

issueRegisteredSession writes the row in the app's own database, stamps its id into the cookie and signs. The strategy checks the row on every request through the same TTL cache; with requireSessionId: true a cookie without a row is refused, so nothing minted outside the registry is a session. Revoke with the registry's revokeSession (this device), revokeAllOtherSessions (the other devices) or revokeAllSessions (everywhere — a deactivation, an account deletion, a security event); the last is on every session store, and a password reset uses it.

Revoking a session and revoking a permission are two different operations, and both now take effect on a live session. This section is the first: kill the row and the holder is signed out. The second is auth.resolveScopes — narrow the role and the caller keeps their session but loses the permission. The two windows are deliberately the same 30 seconds and both invalidate to zero the same way, so there is one number to reason about rather than a second one you discover later.

When to use a longer / shorter lifetime

Use case Lifetime
Consumer SaaS, low-stakes 30 days
B2B SaaS, sensitive data 7 days (default)
Admin / billing dashboards 1 day + idle timeout
Banking / health / compliance 30 minutes + sliding window

Sliding-window auto-renewal ships: the session payload carries an iat, and verifySessionKeyed returns a renew flag once the session crosses the renewal threshold (default 70% of its lifetime). The plugin acts on that signal on authenticated /auth/* responses (and whenever the cookie verified under the previous rotation key): the response carries a fresh Set-Cookie signed with the current key and the session's original lifetime, and the sessions row's expiresAt slides forward with it. GET /auth/session is the probe built for this — it returns the current subject plus renewed: true|false, so a client that pings it periodically keeps an active session alive while an idle one still expires on schedule. (Renewal is an HTTP-response mechanism — rpc frames over the WebSocket can't set cookies.)

Verification details

// What readSession does:
// 1. Parse cookie → { payloadB64, sigB64 }
// 2. Compute expected = hmacSha256(payloadB64, secret)
// 3. Constant-time compare expected vs. sigB64
// 4. Decode payload (rejects a superseded payload version)
// 5. Check exp (reject if exp < now)
// 6. Return the decoded SubjectIdentity

All steps use constant-time comparisons to defeat timing attacks. Implementation lives in @voltro/plugin-auth/session.ts — read it for the full story.

RPC connection lifetime is not a login session

Server executors receive ctx.request.connection: a server-generated id, transport: 'websocket' | 'http', and an AbortSignal named signal. A WebSocket keeps this identity across calls. Reconnects, other listeners and new processes receive different IDs; each HTTP RPC request has its own lifetime. Client-provided IDs, query parameters and headers do not select this identity. Internal calls and workflows have no transport connection.

import type { AppContext } from '@voltro/runtime'

export const liveSocketIdentity = (ctx: AppContext): string | null => {
  const connection = ctx.request.connection
  if (connection?.transport !== 'websocket' || connection.signal.aborted) return null
  return connection.id
}

The numeric ctx.request.clientId (also ConnectionInfo.clientId) is an opaque process-wide handle for framework operations such as credential rebinding. It is distinct across HTTP/WebSocket protocols and listeners, but must not be persisted as an identity across process restarts. Effect handlers can resolve the same live connection with getRpcConnection(clientId) from @voltro/runtime.

Disconnect, HTTP completion and listener shutdown abort the signal, remove connection credentials/expiry and interrupt both registered streams and the RPC core's pending handlers. Teardown has one owner; applications must not consume the RPC protocol's disconnect mailbox themselves.

This proves a transport lifetime, not authentication or room membership. Anonymous callers also have a connection. Keep permission decisions explicit, and coordinate persisted membership with transaction commit and disconnect; the signal alone is not a durable database cleanup mechanism after a process crash.

Transaction-aware connection state

ctx.connectionState provides get(key), has(key), set(key, value) and delete(key) for server-owned application state on this connection. It is absent for internal calls. The shared dev/serve context and makeTestContext({ connection }) use the same implementation. For multi-connection tests, withRpcConnection(ctx, otherConnection) from @voltro/testing shares the test store and services while selecting the other connection's state. Pass undefined to model an internal call. The helper accepts an explicit connection fixture or a real listener's connection; it does not create an identity or grant permissions.

Inside a mutation, writes and deletes enter the existing commit queue. Reads see committed state only, even inside the writing handler. Rollback or a retried transaction discards the losing attempt's queued changes. Outside a transaction, writes are immediate. Values must be structured-cloneable and are copied on both write and read, so mutating a returned object cannot grant a permission outside the commit boundary.

import type { AppContext } from '@voltro/runtime'

// Call only AFTER the application's join/capacity checks and database writes.
export const recordDocumentJoin = (ctx: AppContext, documentId: string): void => {
  const connection = ctx.request.connection
  if (connection?.transport !== 'websocket' || connection.signal.aborted || !ctx.connectionState) {
    throw new Error('A live WebSocket connection is required')
  }
  ctx.connectionState.set(`document:${documentId}`, true)
}

Disconnect, HTTP completion and shutdown clear the state; a delayed commit cannot recreate it and a reconnect has no previous entries. It is local to the native connection object, not selected by a caller-provided ID. It does not persist or replicate database membership, and it does not clean database rows after a crash. It is not a user/tenant permission cache: subject changes on the same socket do not change the connection. Namespace application keys by their actual ownership boundary and keep revocable user permissions in the normal authorization resolver. Never promote an unverified client payload to authority by storing it here.

Browser-side considerations

The session cookie is HttpOnly — the browser cannot read it from JS. This is deliberate.

In React components on the SSR pass, you can decode the subject from the request via the layout (see React on the web side). On the client (post-hydration), there's no fresh cookie read — the layout's subject is the one you have until next page load.

That's enough for UI gating. For actions, the server verifies the cookie on every request — the client never needs to "have" the session, it just needs to send it (which the browser does automatically).