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:
- Reads the
voltro:sessioncookie. Absent →skip. - HMAC-verifies it. Bad signature or expired →
failed(a forged cookie doesn't fall through to another IdP). - Valid →
matched, stampingmetadata.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
apiKeyStrategyalready ships from@voltro/protocol/apikey(prefixedAuthorization: Bearer <prefix>_<token>, sha256-hashed lookup, scopedapiKeysubject). 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
- External identity providers — WorkOS, Kinde, Clerk, and the shared
jwtBearerStrategy.