API · Auth
Real user authentication wired turnkey with @voltro/plugin-auth — password sign-up/in/out over HttpOnly session cookies, a strategy that resolves the session into a typed Subject, and a session.me action proving it. Zero-infra boot.
Real user authentication, wired turnkey. Password sign-up / sign-in / sign-out over HttpOnly session cookies, a strategy that resolves the session into a typed Subject on every rpc call, and a session.me action that proves the loop end-to-end. Boots with zero infra (memoryUserStore()). Template id: api-auth.
Scaffold
voltro create-project acme --api=api-authThe shipped .env supplies a DEV-ONLY VOLTRO_SESSION_SECRET so it boots out of the box — replace it for production (see Environment).
What ships
apps/acme/api/
├── app.config.ts # authRoutesPlugin + voltroPasswordStrategy + memoryUserStore
├── .env # DEV-ONLY VOLTRO_SESSION_SECRET
├── package.json # + @voltro/plugin-auth
├── database/schema.ts # core actors + tenants (users live in the memory store)
└── actions/
├── me.action.ts # session.me descriptor — browser-safe
└── me.action.server.ts # returns ctx.request.subjectThe two halves
app.config.ts wires both, sharing ONE secret:
import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@voltro/plugin-auth'
const SECRET = process.env.VOLTRO_SESSION_SECRET ?? 'dev-only-unsafe-session-secret-change-me'
export default {
type: 'api' as const, name: 'AcmeApi', store: 'memory' as const,
plugins: [
authRoutesPlugin({
store: memoryUserStore(),
secret: SECRET,
defaultTenantId: 'acme',
cookieSecure: process.env.NODE_ENV === 'production', // ← see the gotcha below
}),
],
auth: { strategies: [voltroPasswordStrategy({ secret: SECRET })] },
}authRoutesPluginmounts the HTTP auth surface under/auth/*(POST /auth/sign-up,/auth/sign-in,/auth/sign-out,GET /auth/csrf, password-reset, magic-link,/auth/sessions, tenant memberships) and signs thevoltro:sessioncookie on success.voltroPasswordStrategyruns in the AuthMiddleware chain on every rpc/ws call, verifies that cookie, and resolves it to aSubject— so handlers read the user viactx.request.subject.
They MUST share the same secret (one signs, the other verifies); the template reads VOLTRO_SESSION_SECRET once and passes it to both.
Reading the Subject — session.me
// actions/me.action.server.ts
const execute = async (_input, ctx) => {
const subject = ctx.request.subject as { type: string; id: string | null; tenantId: string | null }
return { type: subject.type, id: subject.id ?? null, tenantId: subject.tenantId ?? null }
}Anonymous before sign-in (type: 'anonymous', id: null); a user Subject once the session cookie resolves.
Gotcha — cookieSecure in dev
Secure cookies are HTTPS-only — over http://localhost a browser (and curl) silently drops a Secure session cookie, so the sign-in → session → authenticated-call loop never authenticates. Setting cookieSecure: process.env.NODE_ENV === 'production' is what makes the loop work locally (off in dev, on over HTTPS in prod).
Try the loop (curl)
State-changing routes are CSRF-protected; session.me is an rpc procedure (invoke it over HTTP via the dev inspect endpoint, which forwards your cookie):
# 1. CSRF token (+ csrf cookie into the jar)
curl -s -c jar.txt http://localhost:4000/auth/csrf # → { "csrfToken": "…" }
# 2. Sign up (sets the HttpOnly session cookie)
curl -s -b jar.txt -c jar.txt -X POST http://localhost:4000/auth/sign-up \
-H 'content-type: application/json' -H 'x-csrf-token: <csrf>' \
-d '{"email":"ada@example.com","password":"hunter2hunter2"}'
# → { "ok": true, "subject": { "type": "user", "id": "u_…", "tenantId": "acme" } }
# 3. session.me with the cookie → you're a user
curl -s -b jar.txt -X POST http://localhost:4000/_voltro/inspect/invoke \
-H 'content-type: application/json' -d '{"tag":"session.me","input":{}}'
# → { "ok": true, "result": { "type": "user", "id": "u_…", "tenantId": "acme" } }Going to production
| Want… | Do |
|---|---|
| Durable accounts | postgresUserStore({ sql }) + store: 'postgres' (manages its own auth tables) |
| Magic-link / password-reset email | pass sendEmail: mailSender(mailService) (from @voltro/plugin-mail) to authRoutesPlugin |
| A login UI | pair with frontend-app — a form POSTing to /auth/sign-in; authenticated rpc carries the cookie automatically |
Anti-patterns
- Forgetting
cookieSecurein dev. TheSecuredefault drops the cookie over http — auth silently fails. See the gotcha above. - Different secrets for the routes plugin vs. the strategy. One signs, the other verifies — they must match. Read
VOLTRO_SESSION_SECRETonce and pass it to both. - Shipping the DEV-ONLY
.envsecret. Generate a real one (openssl rand -hex 32); rotating it invalidates every session. - Trusting
x-tenantin production. The dev resolver reads it unauthenticated — this template's session strategy is the real path; don't leave the header as your auth.