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-auth

The 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.subject

The 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 })] },
}
  • authRoutesPlugin mounts 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 the voltro:session cookie on success.
  • voltroPasswordStrategy runs in the AuthMiddleware chain on every rpc/ws call, verifies that cookie, and resolves it to a Subject — so handlers read the user via ctx.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 cookieSecure in dev. The Secure default 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_SECRET once and pass it to both.
  • Shipping the DEV-ONLY .env secret. Generate a real one (openssl rand -hex 32); rotating it invalidates every session.
  • Trusting x-tenant in production. The dev resolver reads it unauthenticated — this template's session strategy is the real path; don't leave the header as your auth.