Auth
Password + session-cookie auth — overview. Full docs in the Authentication section.
@voltro/plugin-auth ships the full server-side auth suite — password hashing (with rehash-on-verify), HMAC session cookies (multi-key rotation + sliding-window auto-renewal), magic-link + password-reset flows, passkeys/WebAuthn (with atomic clone detection), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, TOTP/MFA with sign-in enforcement + recovery codes — plus React glue (SubjectProvider, useSubject, RequireAuth) and a browser passkey ceremony helper. Identity is a pluggable strategy protocol — the password flow is the default, and external IdPs stack on top.
The single authRoutesPlugin() mounts every auth HTTP route under /auth — you don't hand-wire endpoints.
Status: ✓ shipped.
This page is a 60-second overview. The dedicated Authentication section has the depth:
- Passwords — scrypt hashing, timing-oracle defence
- Sessions — HMAC-SHA256 signed sessions
- The Subject — the typed
ctx.subjectmodel - Auth strategies — the
AuthStrategyprotocol + chain - External identity providers — WorkOS, Kinde, Clerk, Auth0, Supabase, OIDC
- HTTP handlers —
handleSignIn,handleSignUp,handleSignOut - User stores —
memoryUserStore,postgresUserStore, custom - React on the web side —
SubjectProvider+ hooks - Cookie security — production checklist
Quick wire-up
Add authRoutesPlugin() to your api's plugins array. It mounts the entire auth surface under /auth on the same listener the rpc server uses — no hand-wired endpoints. The documented email path forwards magic-link + password-reset mail to @voltro/plugin-mail via mailSender(...):
// app.config.ts
import { authRoutesPlugin, postgresUserStore, mailSender } from '@voltro/plugin-auth'
import { mailPlugin, MailService } from '@voltro/plugin-mail'
import { bindConnectionSubject } from '@voltro/runtime'
import { Effect } from 'effect'
// `mail` is the yielded MailService from the mail plugin's services layer.
const auth = (mail: MailService) =>
authRoutesPlugin({
store: postgresUserStore(sql), // sql: a provided @effect/sql SqlClient
secret: process.env.VOLTRO_SESSION_SECRET!,
defaultTenantId: 'public',
cookieSecure: process.env.NODE_ENV === 'production',
appBaseUrl: 'https://app.example.com',
sendEmail: mailSender(mail), // ← plugin-mail synergy (the default wiring)
rebind: bindConnectionSubject, // ← switch-tenant rebinds the live connection
passkey: {
rpId: 'example.com',
rpName: 'Acme',
origin: 'https://app.example.com',
},
})
export default {
type: 'api' as const,
name: 'myApi',
plugins: [ mailPlugin({ provider: 'resend', from: 'Acme <hi@acme.com>' }) /*, auth(...) */ ],
}sendEmail is a plain injected hook ({ to, subject, html, text, kind, actionUrl } => Promise<void>); mailSender(mail) is the first-class adapter that forwards it to MailService.send. Omit sendEmail and the magic-link / reset routes still mint + persist the token but can't deliver it (they return 202 so account existence never leaks).
// app.config.ts — add identity strategies (the built-in password cookie always runs first)
import { jwtBearerStrategy } from '@voltro/protocol/jwt'
import { apiKeyStrategy } from '@voltro/protocol/apikey'
export default {
type: 'api' as const,
name: 'myApi',
auth: { strategies: [ jwtBearerStrategy({ /* … */ }), apiKeyStrategy({ /* … */ }) ] },
}The plugin:
- Mounts every auth route under
/authviaauthRoutesPlugin()(also re-exports the underlying Effect-typed handlers —handleSignIn,handleSignUp,handleMagicLinkRequest,handleSwitchTenant, … — if you'd rather mount a subset yourself). - Resolves
ctx.subjectviaAuthMiddlewarerunning the strategy chain — the built-involtroPasswordStrategyreads the session cookie. - Exposes the typed user store via
postgresUserStore(sql)(synchronous; the caller owns theSqlClient) ormemoryUserStore()for dev/tests. - Contributes the auth tables (
usersTable,sessionsTable,membershipsTable,authTokensTable,passkeysTable) viaauthTables.
Routes mounted under /auth
| Route | Auth | Purpose |
|---|---|---|
POST /auth/sign-in · /sign-up · /sign-out |
— | password flow (sign-out also revokes the session row). For an MFA-enrolled user, sign-in returns an mfa-required challenge, not a session — see below |
POST /auth/mfa/verify |
— | complete the second factor (TOTP or recovery code) with the pending token → issues the real session |
GET /auth/session |
cookie | session probe: current subject + renewed flag; re-issues the cookie when the sliding window (or a key rotation) asks for it |
GET /auth/csrf |
— | issue a double-submit CSRF token |
POST /auth/magic-link · /magic-link/callback |
— | passwordless sign-in |
POST /auth/password-reset · /password-reset/confirm |
— | reset flow (confirm revokes all sessions) |
GET /auth/sessions · POST /auth/sessions/revoke · /sessions/revoke-others |
cookie | device list + revocation |
GET /auth/memberships · POST /auth/switch-tenant |
cookie | multi-tenant membership + active-tenant switch |
POST /auth/mfa/enroll/start · /enroll/verify · /unenroll · /recovery-codes/regenerate |
cookie | TOTP enrolment (needs mfa: { issuer } config); enroll/verify returns one-time recovery codes |
POST /auth/passkey/register/options · /register/verify |
cookie | passkey enrolment |
POST /auth/passkey/assert/options · /assert/verify |
— | passkey sign-in |
State-changing authenticated routes require a valid x-csrf-token header matching the voltro:csrf cookie.
Schema
import { authTables } from '@voltro/plugin-auth/schema'
// authTables = [usersTable, sessionsTable, membershipsTable, authTokensTable,
// passkeysTable, recoveryCodesTable, passkeyChallengesTable]Spread authTables into your database/index.ts handle + voltro migrate creates the tables.
Multi-tenant memberships + switch-tenant
A user belongs to MANY tenants. The Subject carries an active tenantId plus its memberships (in metadata.memberships, read via subjectMemberships(subject)). POST /auth/switch-tenant validates the user is a member of the target tenant, re-issues the session cookie with the new active tenant, and — when invoked over the live WebSocket with a clientId — rebinds the connection's Subject via bindConnectionSubject (the dispatcher re-scopes active subscriptions on rebind).
MFA / TOTP sign-in enforcement
MFA is a real sign-in gate, not just enrolment. Enrolment (POST /auth/mfa/enroll/*, needs mfa: { issuer } in the plugin config) stores a TOTP secret on the user (generateTotpSecret → otpauthUrl for the QR); enroll/verify confirms the first code, marks the user enrolled, and returns a one-time batch of recovery codes (shown once, stored hashed).
Sign-in enforcement is automatic for any enrolled user — no config flag:
- Challenge, not session. When an enrolled user posts valid credentials to
POST /auth/sign-in, the response is{ ok: true, mfaRequired: true, pendingToken }— a short-lived (5 min), single-use token whose hash is stored inauthTokens. No session cookie is issued. The pending token is NOT a session; it can only redeem the second factor. - Verify → session. The client posts the pending token plus a
code(6-digit TOTP) — or arecoveryCodeas the lost-authenticator fallback — toPOST /auth/mfa/verify. The token is redeemed atomically (single-use, so a guessed code can't be retried against the same challenge), the code is checked against the stored secret (±1 step drift, constant-time compare), and only then is the real session issued — through the sameissueSessionpath as password sign-in, so rotation, sliding-window renewal, and revocation all apply.
A non-enrolled user still gets a session directly from sign-in (unchanged). Recovery codes are single-use; regenerate them with POST /auth/mfa/recovery-codes/regenerate (wipes the prior set) and they are wiped on unenroll. The TOTP secret is stored plaintext on the user row — put it behind your database's at-rest encryption.
Passkeys / WebAuthn
Server verification (verifyRegistration / verifyAssertion, node:crypto-only) checks the challenge, origin, rpId hash, and signature, and enforces a strictly-increasing signature counter (clone detection). Attestation is intentionally skipped (the 90% path). The browser ceremony helper lives in the /web subpath:
import { registerPasskey, signInWithPasskey, isPasskeySupported } from '@voltro/plugin-auth/web'
if (isPasskeySupported()) {
await registerPasskey({ userId, userName }, { csrfToken }) // navigator.credentials.create
await signInWithPasskey({ userId }) // navigator.credentials.get
}Clone detection is atomic
Per the WebAuthn spec, an authenticator's signature counter must strictly increase across assertions; a counter that regresses (or fails to advance) means a cloned credential. The counter bump is a store-level compare-and-swap — advancePasskeyCounter issues one UPDATE … WHERE counter < :new RETURNING statement, so the check and the write are a single atomic operation. Two replicas racing the same assertion can't both succeed: exactly one UPDATE matches, and the loser (zero rows advanced, counter unchanged) is rejected with counter_regressed. The guarantee holds across replicas, not just within one process.
Multi-replica: bring your own ChallengeStore
A passkey ceremony is two round-trips (.../options mints a challenge, .../verify redeems it). The default memoryChallengeStore() holds challenges in process memory — fine for a single node, but on ≥2 replicas the options and verify requests can land on different nodes and the challenge is missing. For any multi-replica deployment, pass a shared store:
import { authRoutesPlugin, dataStoreChallengeStore } from '@voltro/plugin-auth'
authRoutesPlugin({
// …store, secret, passkey…
challengeStore: dataStoreChallengeStore(sql), // shared table, survives across replicas
})dataStoreChallengeStore(sql) persists challenges in the passkeyChallenges table (shipped in authTables) — single-use (take is one DELETE … RETURNING, so concurrent verifies can't both redeem) and short-lived (put stamps expiresAt; an expired row reads as absent). The caller owns the SqlClient lifecycle, same contract as postgresUserStore.
BYO contract. Any ChallengeStore you supply implements two Effect-returning methods, keyed by <userId>:<ceremony>:
interface ChallengeStore {
put: (key: string, challenge: string, ttlSeconds: number) => Effect.Effect<void>
take: (key: string) => Effect.Effect<string | null> // read AND delete (single-use); null if absent/expired
}take must delete on read (single-use) and return null for an absent, already-taken, or expired key. Back it with any shared, low-latency store (Postgres via dataStoreChallengeStore, or a Redis/KV of your own) so all replicas see the same challenges.
Multi-key session-secret rotation
To rotate without logging everyone out: set the new secret as VOLTRO_SESSION_SECRET, move the old one to VOLTRO_SESSION_SECRET_PREVIOUS for one max-session-lifetime window, then drop the _PREVIOUS var. Every verify path is keyed — the framework's rpc auth chain, the plugin's /auth/* routes, and readSession — trying current first, then previous. A cookie that verified under the previous key is re-issued under the current key on the next authenticated /auth/* response (GET /auth/session triggers this proactively), so live sessions migrate onto the new key during the window. The optional VOLTRO_SESSION_KID / VOLTRO_SESSION_KID_PREVIOUS vars label the keys (non-secret; default k0 / k-previous). Prefer explicit config over env? authRoutesPlugin / AuthConfig accept secrets: { current, previous? } directly; resolveSessionSecrets() (in @voltro/protocol/session) is the env reader underneath.
Sliding-window auto-renewal
Sessions carry an iat. verifySessionKeyed returns a renew flag once the session crosses the renewal threshold (default 70% of its lifetime); authenticated /auth/* responses act on it by re-issuing the cookie with its original lifetime (and sliding the sessions row's expiresAt forward), so an active user never gets logged out mid-session. GET /auth/session is the probe a client pings to keep a session sliding — rpc frames over the WebSocket can't set cookies, so renewal is an HTTP-response mechanism.
Request-time session revocation
Revoking a session (device-list revoke, "sign out other devices", sign-out, or password-reset's revoke-all) actually terminates the live cookie: on every verify, the cookie's metadata.sessionId is checked against the sessions table through an in-process TTL cache (default 30s, tune via authRoutesPlugin({ sessionRevocation: { ttlMs } })). A kill is instant on the process that performed it (inline cache invalidation) and takes effect within the cache window on other replicas. The plugin carries a pre-wired auth.sessionStrategy (same store, same cache) that voltro dev / voltro serve slot into the rpc auth chain automatically — so WebSocket calls reject a revoked session exactly like the HTTP routes. A cookie minted by hand via issueSession (no sessions row, no sessionId) stays purely stateless and can't be revoked this way.
Post-authentication subject guards
Every login path (password, MFA, magic-link, passkey) resolves WHO you are, then — right before minting the session cookie — runs the configured subject guards against the authenticated UserRecord. A guard returns { ok: true } to allow or { ok: false, code, message } to VETO; the first rejection wins, the login returns a 403 carrying the guard's code as the body error, and no session is issued. Sign-up is exempt — it creates a brand-new user no guard could yet reject.
This is a general seam, not tied to any single concern — "account deactivated", "email not verified", "tenant suspended", "must accept new terms" all fit. Pass guards on subjectGuards:
import { authRoutesPlugin, type SubjectGuard } from '@voltro/plugin-auth'
import { Effect } from 'effect'
const suspendedTenants = new Set(['tenant-under-review'])
const tenantSuspendedGuard: SubjectGuard = (user) =>
Effect.succeed(
suspendedTenants.has(user.tenantId)
? { ok: false, code: 'tenant_suspended', message: 'tenant is suspended' }
: { ok: true },
)
authRoutesPlugin({
store,
secret: process.env.VOLTRO_SESSION_SECRET!,
defaultTenantId: 'acme',
subjectGuards: [tenantSuspendedGuard],
})The canonical guard ships in @voltro/plugin-deactivation: deactivationGuard() refuses login when the user's deactivatedAt is set — making the deactivation() mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See deactivation. With no guards configured, every authenticated user proceeds exactly as before.
Rehash-on-verify
verifyPasswordWithRehash parses the scrypt cost out of the stored hash; when it's below the current cost, a successful sign-in returns a freshly-minted replacement that handleSignIn persists via UserStore.updatePassword — passwords strengthen silently on the next login, no forced reset.
When to use a different auth strategy
plugin-auth's password flow is the default. For specific needs:
- Existing user base in an IdP (Okta, Auth0, Clerk, WorkOS, Kinde, Supabase) — add the matching external IdP strategy to the chain. The framework keeps its own
Subject+tenantId. - Any OIDC provider —
@voltro/plugin-auth-oidc'soidcStrategy, orjwtBearerStrategyfrom@voltro/protocol/jwtdirectly. - Headless / programmatic callers —
apiKeyStrategyfrom@voltro/protocol/apikey.
For 95% of apps, the password flow is the right starting point. The depth lives in the Authentication section.