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:

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 /auth via authRoutesPlugin() (also re-exports the underlying Effect-typed handlers — handleSignIn, handleSignUp, handleMagicLinkRequest, handleSwitchTenant, … — if you'd rather mount a subset yourself).
  • Resolves ctx.subject via AuthMiddleware running the strategy chain — the built-in voltroPasswordStrategy reads the session cookie.
  • Exposes the typed user store via postgresUserStore(sql) (synchronous; the caller owns the SqlClient) or memoryUserStore() for dev/tests.
  • Contributes the auth tables (usersTable, sessionsTable, membershipsTable, authTokensTable, passkeysTable) via authTables.

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 (generateTotpSecretotpauthUrl 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:

  1. 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 in authTokens. No session cookie is issued. The pending token is NOT a session; it can only redeem the second factor.
  2. Verify → session. The client posts the pending token plus a code (6-digit TOTP) — or a recoveryCode as the lost-authenticator fallback — to POST /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 same issueSession path 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-swapadvancePasskeyCounter 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's oidcStrategy, or jwtBearerStrategy from @voltro/protocol/jwt directly.
  • Headless / programmatic callersapiKeyStrategy from @voltro/protocol/apikey.

For 95% of apps, the password flow is the right starting point. The depth lives in the Authentication section.