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 { subject, exp, iat, kid? } — the whole typed Subject round-trips inside the cookie, e.g. { "subject": { "type": "user", "id": "user-id", "tenantId": "tenant-id" }, "exp": 1736294400, "iat": 1735689600 }. The subject's own id is the identity; 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.

Issuing

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

// Positional args: (subject, secret, options?). Returns { value, setCookie }.
const { value, setCookie } = issueSession(
  subject,                            // a full Subject, e.g. subjectFromUser(user)
  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 secureHttpOnly 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

Returns null for:

  • Missing cookie
  • Tampered payload (signature mismatch)
  • Expired session (exp < now)

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. One deliberate gap: a cookie minted by hand via issueSession without a sessions row carries no sessionId and stays purely stateless.

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
// 5. Check exp (reject if exp < now)
// 6. Return the decoded Subject

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

Browser-side considerations

The session cookie is HttpOnlythe 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).