Auth strategies

The AuthStrategy protocol — how Voltro resolves a Subject from a request, chains multiple identity providers, and lets you plug in your own.

Everything above this page describes the built-in password + session flow. This page describes the protocol underneath it: how the framework turns an incoming request into a Subject, and how you swap or stack the mechanism that does it — password cookies, an external IdP, an API key, or your own scheme — without touching handler code.

An auth strategy is the unit of pluggability. The built-in password auth is one strategy (voltro-password); WorkOS / Kinde / Clerk are others; you can write your own. The framework evaluates them as a chain and produces a Subject.

The contract

A strategy answers one question per request: "is this my request, and if so, who is it?"

import type { AuthStrategy } from '@voltro/protocol'

interface AuthStrategy {
  readonly id: string                          // 'voltro-password', 'workos', …
  readonly resolve: (input: AuthStrategyInput) =>
    StrategyResolution | Promise<StrategyResolution>
}

interface AuthStrategyInput {
  readonly headers: Readonly<Record<string, string | undefined>>
  readonly clientId: number                    // per-connection id (for soft re-auth)
}

resolve returns one of three verdicts:

Verdict Meaning Composer does
{ kind: 'skip' } Not my request (e.g. my cookie is absent). Try the next strategy.
{ kind: 'matched', subject } Mine, and here's the verified Subject. Use it. Stop.
{ kind: 'failed', reason } Mine, but verification failed (bad signature, expired). Bail to anonymous + log. Do NOT try the next strategy.

The failed-stops-the-chain rule is a security decision, not an ergonomic one: a forged workos token must not get a second chance to be accepted by some other strategy. A validation failure is treated as a potential attack, not a "wrong door".

Strategies must be fast on the no-match path — a cookie-name substring check, no IO — because every strategy runs on every request until one matches. Do JWKS fetches / DB lookups only after you've confirmed the request is yours, and cache them.

Composing the chain

composeAuthStrategies turns an ordered list of strategies into a single resolver. First matched wins; first failed short-circuits to anonymous.

import { composeAuthStrategies } from '@voltro/protocol'
import { voltroPasswordStrategy } from '@voltro/plugin-auth'
import { workosStrategy } from '@voltro/plugin-auth-workos'

const resolve = composeAuthStrategies(
  [
    voltroPasswordStrategy(),                  // try our own session cookie first
    workosStrategy({ clientId: process.env.WORKOS_CLIENT_ID! }),  // then WorkOS
  ],
  {
    onStrategyFailed: ({ strategyId, reason }) =>
      log.warn('auth strategy failed', { strategyId, reason }),
  },
)

Order matters: put the cheapest / most-common strategy first. When no strategy matches, the resolver returns an anonymous Subject scoped to the x-tenant header (or a custom fallback you supply).

Wiring it into the app

The composed resolver becomes the runtime's AuthMiddleware — the per-request middleware that populates SubjectService so every handler can yield* SubjectService (or read ctx.subject). On a single-strategy password app you never touch this; the plugin wires voltroPasswordStrategy for you. You only assemble the chain explicitly when you add a second strategy:

import { AuthMiddleware } from '@voltro/protocol'
import { Layer } from 'effect'

export const AuthLayer = Layer.succeed(
  AuthMiddleware,
  AuthMiddleware.of(({ headers, clientId }) => resolve({ headers, clientId })),
)

The runtime injects a connection-override fast-path ahead of the chain so a soft re-auth (auth.signin rebinding the WebSocket's subject after a credential check) is honoured without re-running strategies. That's why AuthStrategyInput carries clientId.

The built-in: voltroPasswordStrategy

The reference implementation, and the proof the protocol isn't a special case for third parties — our own auth is just a strategy:

voltroPasswordStrategy({
  // secret?: defaults to resolveSessionSecret() (VOLTRO_SESSION_SECRET)
  // cookieName?: defaults to 'voltro:session'
})

Its resolve:

  1. Reads the voltro:session cookie. Absent → skip.
  2. HMAC-verifies it. Bad signature or expired → failed (a forged cookie doesn't fall through to another IdP).
  3. Valid → matched, stamping metadata.provider = 'voltro-password'.

Fully synchronous, zero IO on no-match. See sessions for how the cookie is minted.

Writing your own strategy

Any object satisfying AuthStrategy works. As an illustration, a minimal header-keyed strategy:

import type { AuthStrategy } from '@voltro/protocol'

const myKeyStrategy = (lookup: (key: string) => Promise<{ id: string; tenantId: string } | null>): AuthStrategy => ({
  id: 'my-key',
  resolve: async ({ headers }) => {
    const key = headers['x-api-key']
    if (!key) return { kind: 'skip' }                 // not my request
    const row = await lookup(key)
    if (!row) return { kind: 'failed', reason: 'unknown api key' }
    return {
      kind: 'matched',
      subject: {
        type: 'apiKey',
        id: row.id,
        tenantId: row.tenantId,
        metadata: { provider: 'my-key' },
      },
    }
  },
})

Drop it into the composeAuthStrategies array. The metadata.provider tag lets handler code pattern-match on which strategy authenticated the caller (see the Subject).

You don't need to hand-roll API-key auth — a production apiKeyStrategy already ships from @voltro/protocol/apikey (prefixed Authorization: Bearer <prefix>_<token>, sha256-hashed lookup, scoped apiKey subject). Use the example above only for genuinely custom schemes the shipped strategy can't express.

Strategies that need a server-side callback

Pure token-verify strategies (the three IdP plugins, the example above) need no server queries — the credential already arrives on the request. A strategy that must run a server-side OAuth code exchange or land a magic link additionally implements mountRoutes:

interface AuthStrategyWithCallback extends AuthStrategy {
  readonly mountRoutes: (router: AuthCallbackRouter) => void   // router.get / router.post
}

The framework mounts those routes on the HTTP router when present (detected via the hasCallbackRoutes type guard). The first-party WorkOS/Kinde/Clerk plugins do not use this — their SDKs run the OAuth flow in the browser and set a cookie the strategy then verifies. mountRoutes exists for custom OIDC flows that can't.

Requiring authentication

Independent of strategy: any handler can demand a real (non-anonymous) caller.

import { assertAuthenticated } from '@voltro/protocol'

const execute = async (input, ctx) => {
  assertAuthenticated(ctx.subject)   // throws Unauthenticated if anonymous
  // …
}

Unauthenticated crosses the wire with its _tag intact, so @voltro/client can auto-redirect to sign-in. It's distinct from a tenant-mismatch ("you're signed in but touching the wrong tenant") — this means "no real identity resolved at all".

Next