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:
payloadis JSON{ v, subject, exp, iat, kid? }, e.g.{ "v": 2, "subject": { "type": "user", "id": "user-id", "tenantId": "tenant-id" }, "exp": 1736294400, "iat": 1735689600 }.vis the payload format version;iatdrives the sliding-window renewal below;kidis the (non-secret) label of the signing key, stamped when signing with a keyed secret set.signatureis 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.
The cookie carries identity, never authority
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/signSessionthrow when handed a subject that carriesscopes. 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/verifySessionreturn aSubjectIdentity. 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)
Clearing
import { clearSessionCookie } from '@voltro/plugin-auth'
res.setHeader('set-cookie', clearSessionCookie({ domain: '.your-product.com', secure: true }))
// voltro:session=; HttpOnly; Secure; …; Max-Age=0clearSessionCookie 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:
- Set the NEW secret as
VOLTRO_SESSION_SECRET(current). - 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_PREVIOUSare optional non-secret labels; they default tok0/k-previous.) - Leave the window open for one max session lifetime, then drop the
_PREVIOUSvar. 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 (orGET /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.
Cookie attributes
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.
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 SubjectIdentityAll 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 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).