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)
readonly store?: DataStore // the app's store, for a DB-backed strategy
}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.
Reading your database — input.store
A strategy that identifies the caller from a row — a session, an API key, a PAT
— gets the app's DataStore on its input:
const dbSession: AuthStrategy = {
id: 'db-session',
resolve: async ({ headers, store }) => {
const token = headers.authorization?.slice('Bearer '.length)
if (token === undefined) return { kind: 'skip' }
if (store === undefined) return { kind: 'skip' }
const [row] = await store.query(sessions.byToken(token))
return row ? { kind: 'matched', subject: toSubject(row) } : { kind: 'failed', reason: 'unknown token' }
},
}It is the boot store, not a request-scoped one — strategies resolve before a
request store exists — and the same value auth.resolveScopes receives.
undefined only while the store is still being built (voltro dev builds it
after the auth chain) and on an app with no store, so a strategy should skip
rather than throw.
Read users, sessions, keys. A strategy that runs domain writes while deciding who the caller is has the two jobs the wrong way round; nothing in the type stops you, and it is still wrong.
What the boot store carries, and what it does not
The line is everything that does not need a Subject — not "less than
ctx.store":
Boot store (input.store, req.store) |
Request store (ctx.store) |
|
|---|---|---|
.encrypted() columns decrypt / encrypt |
✓ | ✓ |
| Array columns round-trip on non-native dialects | ✓ | ✓ |
| Tenant scope | — | ✓ |
| Soft-delete filter | — | ✓ |
| Audit-column stamping | — | ✓ |
| Row-level security | — | ✓ |
The right-hand four need a resolved Subject, and a strategy runs before one
exists — so a read of tenant-owned rows here must derive and apply that scope
itself. The first two do not, and getting them wrong is silent: a .encrypted()
column read raw hands back the string enc:v1:…, which compares, concatenates,
renders and logs perfectly well, and simply never matches the token you compare
it to.
The soft-delete row is the one to read twice if you are porting raw SQL onto
this store. A read here behaves the way your SQL did and returns tombstones —
the store does not start appending deletedAt IS NULL behind you. So a lookup
that must see a soft-deleted row (a login that revives a returning user, say)
needs no opt-out and no .withDeleted(); that opt-out belongs to ctx.store,
which does apply the filter. Assuming the filter is present is the more
expensive mistake of the two: it turns a working login into a "not found →
insert → unique violation on email", and nothing about the code says so.
This is also what changes when you move a read off hand-written SQL and onto
the store. Raw SQL sees ciphertext and you decrypt it yourself — decryptField
from @voltro/runtime is the escape hatch for exactly that. Through either
store you get plaintext, so a hand-rolled decryptField on the way out will now
be handed a plaintext value; decryptField passes a non-ciphertext value
through unchanged, so the double call is harmless, but the manual step is no
longer doing anything.
Reading a plugin's own tables
A plugin's tables are declared through extendSchema like any others, so they
are in the same registry and the same store reads them. A public route that
needs a row a plugin wrote — a storage reference for an avatar proxy, say —
reads it directly:
const [ref] = await req.store.query(
queryFor(storageObjects).where(eq('id', objectId)).descriptor,
)Two things to keep in mind. The table is the plugin's contract with itself, not with you, so it can change shape in any release — pin the version if you depend on it. And this store applies no tenant scope, so a route reading a tenant-owned plugin table must filter by tenant itself, from something the request proves rather than something it claims.
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).
Roles from your database — auth.resolveScopes
If your authorization is a database ROLE rather than a scope on the token, the framework cannot see it. voltro check's rbac/unguarded-mutation reports every such write as unguarded — correctly, because nothing about the decision is declared — and the declarative alternative is unusable for you: subjects that come from an external IdP carry no scopes, so requireScope('employee:admin') would lock out every real user. One app measured 1566 findings it had no way to act on.
resolveScopes closes that. It runs after a strategy matches, on every request, and resolves the caller's authority from whatever source you like:
// app.config.ts
export default defineApiConfig({
auth: {
resolveScopes: async (subject, { store }) => {
if (store === undefined) return { kind: 'unavailable', reason: 'store not ready' }
const role = await readRole(store, subject.id)
return {
kind: 'authoritative',
scopes: role === 'admin' ? ['employee:admin', 'employee:read'] : ['employee:read'],
}
},
},
})For a cookie-authenticated caller this is not a supplement — it is the only place authority comes from. The session cookie carries a SubjectIdentity with no scopes field, so the strategy establishes nothing to add to. An app that gates on scopes and wires no resolver has callers with no scopes, which is the fail-closed direction.
The same authorization is now declarable on the descriptor:
export const payrollList = defineQuery({
name: 'payroll.list',
guards: [requireScope('employee:admin')], // visible in the manifest, checkable in CI
…
})Three answers, and picking the right one is the point
| Return | Meaning | Effect |
|---|---|---|
['a', 'b'] — a bare array |
grant (the shorthand; identical to { kind: 'grant', scopes }) |
unioned onto whatever the strategy established |
{ kind: 'authoritative', scopes } |
this resolver is the complete answer | replaces — anything not listed is removed |
{ kind: 'unavailable', reason } |
the authority source could not be reached | the request fails closed with Unauthenticated, and reason reaches onStrategyFailed |
An already-written resolver returning an array keeps its exact meaning, with no compiler error suggesting otherwise.
Why this is three tags and not a boolean. The hook used to be union-only, on the reasoning that a resolver which can silently subtract is a resolver whose bad day is indistinguishable from a policy decision — a DB blip that returns no rows would read as "this user has no permissions" and be applied as such. That hazard is real. But union-only also made narrowing impossible: removing a permission from a role had no effect on anyone already signed in, because the only hook that could have observed it was structurally forbidden from removing anything.
The empty array is what forced the split. Under a grant shape [] has to mean "no extra scopes"; under a replace shape it has to mean "no scopes at all"; and a failed lookup produces it under both. Three meanings, one value — so each got its own tag, and none of them is what you get by accident. Return unavailable, not [], when a lookup fails.
You get the app's DataStore. A role lives in the database, and without it the only way to reach one was a second connection path beside the framework's — to the same database the request store opens a moment later. It is the BOOT store, not a request-scoped one: strategies resolve before a request store exists, so it is undefined while the store is still being built. Return { kind: 'unavailable', … } then rather than guessing — under the old contract [] was the safe answer there, and it no longer is.
Scopes only — never a Subject. The hook cannot change id or tenantId: identity belongs to the auth strategy, and a hook that could rewrite it would be a forgery surface.
It does not run for anonymous callers — there is no identity to look a role up for.
It also answers for durable workflows — read ctx.origin first
A workflow's start context persists the caller's identity, never their scopes: a json() column read back by another cluster runner days later is authority frozen and made durable, which is the session cookie's old defect one layer down. So a resumed run asks this resolver what its caller may do, on every execution attempt:
resolveScopes: async (subject, ctx) => {
if (ctx.origin === 'workflow') return rolesFromDb(subject, ctx.store)
return rolesFromHeader(ctx.headers) // the request path, unchanged
}ctx.origin is 'request' | 'workflow'. For 'workflow' there is no request: ctx.headers is {} and ctx.clientId is undefined (its type is number | undefined, which is where a resolver reading it sees the compile error). Empty rather than fabricated — a resolver that needs headers has to be able to branch instead of silently receiving a bag that is always empty.
Three consequences worth stating plainly:
- An app that wires no resolver gets workflow runs with no scopes. Fail-closed, and the same default a cookie-authenticated request already has.
{ kind: 'unavailable' }fails the execution attempt, rather than downgrading it. A run that quietly skips the branch it was not allowed to take is indistinguishable from one whose business logic said no. Fix the source andvoltro workflows redrive.- A run with no recorded caller at all — a bootstrap, or one whose start-context row aged out — runs as the framework's
SYSTEM_SUBJECTand is not put through your resolver. It already states its own authority, and it carriestenantId: null, which the tenant scope reads as "every tenant". That is why the framework does not promote a caller-owned workflow to it: that would trade frozen authority for cross-tenant visibility.
scopeCache applies to the request path only. One resolution per run attempt is not a hot path, and a run that lasts days must not inherit a window sized for a burst of requests.
Narrowing is audited. auth.onScopesNarrowed is called whenever an authoritative resolution removed scopes the strategy had established, with { strategyId, subjectType, subjectId, removed, granted }. It fires on a fresh resolution rather than on a cache replay, so a narrowed caller logs once per window instead of once per request.
Wired identically under voltro dev and voltro serve.
The staleness window, and how to make it zero
The framework caches the resolution for you. The window is auth.scopeCache, and its default is 30 seconds — deliberately the same window the session-revocation check already used, so the two per-request store reads miss together and there is one number to reason about rather than a second one you discover later.
That number is the lag between "an admin removes a role" and "every replica enforces it". Three ways to shorten it:
// app.config.ts
import { makeScopeCache, scopeCacheKey } from '@voltro/protocol'
// 1. Keep the default: a role change lands within 30s, everywhere. Nothing to write.
// 2. Resolve on every request. Staleness zero, one store read per request.
export default defineApiConfig({
auth: { resolveScopes, scopeCache: { ttlMs: 0 } },
})
// 3. Keep the cache AND get zero where it matters: hold the handle and drop the
// entry from whatever changes a role.
export const scopeCache = makeScopeCache({ ttlMs: 30_000 })
export default defineApiConfig({
auth: { resolveScopes, scopeCache },
})
// …in the mutation that grants or removes a role:
scopeCache.invalidate(scopeCacheKey(subject)) // instant on this process, ttlMs elsewhere
scopeCache.invalidateAll() // when a ROLE's definition changed, not one membershipVOLTRO_AUTH_SCOPE_CACHE_TTL_MS overrides the default per deployment; an explicit ttlMs in code wins over the variable. Set scopeCache: false when your resolver reads anything beyond the subject's identity (a header, a request path) — the cache key is type + tenantId + id and nothing else, so a resolver that varies on something outside that key must not be cached.
tenantId is in the key on purpose: a user keeps their id across a tenant switch and their authority does not.
The honest cost. This is one extra store read per subject per window, on a path that was already doing one of exactly this shape for session revocation. An unavailable verdict is never cached — caching it would stretch one blip into a window of denials and hide the recovery.
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 })),
)Nothing runs ahead of the chain. A soft re-auth — auth.signin over the live WebSocket, a tenant switch — calls bindConnectionCredential(clientId, { cookies }) from @voltro/runtime, which patches the connection's headers; the chain then runs on those headers exactly as it would for a fresh request. That's why AuthStrategyInput carries clientId.
This used to be a fast path that returned a stored Subject and skipped the chain, and the cost was everything downstream of the strategy: session revocation (it lives inside the strategy), resolveScopes, the scope cache, and the credential-expiry bound — for the whole life of the connection, with a 24-hour idle sweep as the only backstop. Patching the credential means a rebound connection has no property a reconnecting one lacks, because it is the same code path.
If you have no credential to present, that is the finding rather than a limitation: a caller that cannot authenticate a fresh request was holding authority no request could obtain.
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.