# Authentication

> How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol.



---

<!-- source: en/authentication/overview.md -->
## Overview

_How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol._

Voltro's auth story ships as a plugin: `@voltro/plugin-auth`. It's server-side primitives + client-side React glue, no third-party redirect dance, no managed-service dependency.

**Built-in (password):** password sign-up / sign-in, cookie-based sessions (multi-key rotation + sliding-window auto-renewal), rehash-on-verify, magic-link + password-reset flows, passkeys (WebAuthn), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, the typed `Subject`, TOTP/MFA enrolment, and a `SubjectProvider` React context + passkey ceremony helper (from the browser-safe `@voltro/plugin-auth/web` subpath). The single `authRoutesPlugin()` mounts every route under `/auth`.

**Pluggable identity:** auth is a [strategy](/docs/authentication/strategies) protocol, not just the password flow. First-party plugins ship for [WorkOS, Kinde, Clerk, Auth0, Supabase, and generic OIDC](/docs/authentication/external-idp); the shared `jwtBearerStrategy` covers any other OIDC provider; API-key callers use `apiKeyStrategy` from `@voltro/protocol/apikey`; you can write your own. Strategies stack, so you can run password + an external IdP side by side.

## The pieces

```text
┌────────────────────────────────────────────────────────────────┐
│  Browser                                                       │
│    POST /auth/signin   ────────┐                               │
│    Cookie: voltro:session=...  │                               │
└───────────────────────────────┬─┘                              │
                                │  Set-Cookie (HttpOnly, Secure) │
                                ▼                                │
┌────────────────────────────────────────────────────────────────┐
│  api app                                                       │
│    handleSignIn → verifyPassword → issueSession                │
│    readSession  → HMAC verify    → Subject                     │
└────────────────────────────────────────────────────────────────┘
                                ▲
                                │  ctx.subject (typed)
                                │
┌───────────────────────────────────────────────────────────────┐
│  Every query / mutation / workflow                            │
│    ctx.subject.id       (string on a user; null on anonymous) │
│    ctx.subject.tenantId (string on a user; string|null anon)  │
└───────────────────────────────────────────────────────────────┘
```

## What's in this section

- [Passwords](/docs/authentication/passwords) — hashing (scrypt), verification, why not bcrypt/argon2
- [Sessions](/docs/authentication/sessions) — issuing, verifying, the signed-cookie payload
- [The Subject](/docs/authentication/subject) — what's in `ctx.subject`, anonymous fallback
- [Auth strategies](/docs/authentication/strategies) — the `AuthStrategy` protocol, the resolver chain, writing your own
- [External identity providers](/docs/authentication/external-idp) — WorkOS, Kinde, Clerk, and the shared `jwtBearerStrategy`
- [HTTP handlers](/docs/authentication/handlers) — `handleSignIn`, `handleSignUp`, `handleSignOut`
- [User stores](/docs/authentication/user-stores) — `memoryUserStore`, `postgresUserStore`, custom backends
- [React on the web side](/docs/authentication/react) — `SubjectProvider`, `useSubject`, `RequireAuth`
- [Cookie security](/docs/authentication/cookies) — the production checklist

## Schema tables

Two tables ship from `@voltro/plugin-auth/schema`:

```ts
import { usersTable, sessionsTable } from '@voltro/plugin-auth/schema'

export const users = usersTable
export const sessions = sessionsTable
```

`postgresUserStore` reads + writes the `users` table only — sessions are stateless signed cookies, so the default flow never touches `sessionsTable`. It ships for apps that opt into DB-backed session enumeration / "sign out other devices"; the stateless default leaves it empty. Don't redeclare these yourself — extend via additional columns in a sibling table linked by `userId`, not by modifying these.

## Why password is the default (and IdPs are strategies, not the base)

You *can* run Clerk / WorkOS / Kinde — they're [first-party strategies](/docs/authentication/external-idp). But the built-in password flow is the default, and external IdPs plug in *underneath* the framework's own `Subject`, because:

- **Cookie sovereignty.** With the built-in flow your app owns the session — no redirect to a third-party SSO domain, no managed-service dependency for a feature every B2B SaaS needs.
- **Multi-tenant model is yours.** Even when an external IdP authenticates the user, `tenantId` and the typed `Subject` stay the framework's, not the vendor's. The IdP's claims ride along under `metadata.claims`; they don't replace the model. See [external IdPs](/docs/authentication/external-idp).
- **Self-host friendly.** Password auth needs nothing external. Reach for an IdP when you want SSO/SCIM/enterprise federation, not because the framework forces a service on you.

Strategies [stack](/docs/authentication/strategies), so adopting an IdP is additive — keep password sessions working while new sign-ups flow through the IdP, no big-bang cutover.



---

<!-- source: en/authentication/passwords.md -->
## Passwords

_scrypt-based password hashing, verification, timing-oracle defence, and why not bcrypt or argon2._

`@voltro/plugin-auth/password` exposes two functions: `hashPassword(plaintext)` and `verifyPassword(plaintext, hash)`. Both return **Effects**, not Promises — `hashPassword` fails with a typed `PasswordEmptyError | PasswordHashError`; `verifyPassword` returns `Effect<boolean>` (never fails — see below). Compose them in `Effect.gen`, or `Effect.runPromise` them at the edge.

## Hashing on sign-up

```ts
import { hashPassword } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const hash = await Effect.runPromise(hashPassword('correct horse battery staple'))
// → 'scrypt$32768$8$1$<saltB64>$<derivedB64>'
```

Store `hash` in the `passwordHash` column of your users table. **Never** store the plaintext.

## Verifying on sign-in

```ts
import { verifyPassword } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const ok = await Effect.runPromise(verifyPassword(input.password, user.passwordHash))
if (!ok) throw new Unauthorised({})
```

`verifyPassword` compares the derived key with `timingSafeEqual`, so timing-based guessing of a correct prefix is mitigated. It also returns `Effect<boolean>` with **no failure channel**: a malformed hash, a parse error, or a scrypt error all collapse to `false` (via `Effect.catchAll`). That's deliberate — surfacing "malformed" vs "mismatched" would let an attacker fingerprint stored-hash structure. You only ever branch on the boolean.

## Why scrypt, not bcrypt / argon2

We picked scrypt deliberately. It's:

| Function | Native dep? | Memory-hard? | OWASP-recommended? |
|---|---|---|---|
| **scrypt** | ✗ (in `node:crypto`) | ✓ | ✓ |
| bcrypt | ✓ (`bcrypt` npm pkg) | ✗ | partially |
| argon2 | ✓ (`argon2` npm pkg) | ✓ | ✓ (preferred) |
| pbkdf2 | ✗ | ✗ | only with high iteration count |

- **No native dep** — `node:crypto.scrypt` is built into Node. argon2 needs a C addon that breaks on Alpine / Bun / serverless runtimes regularly.
- **Memory-hard** — defeats GPU brute-forcing the way bcrypt + pbkdf2 don't.
- **OWASP-acceptable** — not their top pick (argon2id is) but explicitly listed as safe.

## Cost parameters

The framework uses scrypt with these defaults:

| Param | Value | Effect |
|---|---|---|
| `N` | `2^15` = 32768 | CPU + memory cost |
| `r` | 8 | block size |
| `p` | 1 | parallelism |

Measured — node v26.3.0, Apple M2 Pro, median of 5 runs at `r=8 p=1 keyLen=32`. Memory is `128 · N · r`, held for the whole derivation:

| `N` | time | memory |
|---|---|---|
| `2^14` | 39 ms | 16 MiB |
| **`2^15`** (this) | **73 ms** | **32 MiB** |
| `2^16` | 156 ms | 64 MiB |
| `2^17` (OWASP's floor) | 271 ms | 128 MiB |

### Why not OWASP's `2^17`

Every row of that table is a cost **your server** pays, per attempt, on an endpoint an anonymous caller controls. Two facts decide it:

- The brute-force lockout that is on by default is keyed by **email** — deliberately, so an unknown address locks exactly like a real one and the lock is not an existence oracle. An attacker who rotates the email field is therefore not rate-limited, and every attempt buys a full derivation. General per-IP rate limiting is opt-in ([`@voltro/plugin-ratelimit`](/docs/plugins/ratelimit)).
- The common deployment target is a small container. At 0.25 vCPU, `2^17` is over a second of CPU and 128 MiB **per anonymous attempt** — one laptop can hold that box down, and ten concurrent logins is an OOM.

Availability is part of security, so the ceiling here is set by what an anonymous caller can make the server spend, not by the offline-cracking table alone. Put a per-IP limiter in front of `/auth` and raising `N` becomes cheap.

The parameters are encoded inline in the hash string (`scrypt$32768$8$1$…`), so a cost bump decodes older hashes and re-encodes them on the next sign-in — see [Rehash-on-verify](/docs/plugins/auth). Hashes minted at `2^14` keep verifying; nothing to migrate.

For high-throughput service-to-service flows that need many auths per second, use API keys instead — `apiKeyStrategy` from `@voltro/protocol/apikey`. Passwords are for humans.

## Timing-oracle defence

A naive sign-in implementation leaks "is this email registered?" via response time:

```ts
// BAD — fast 401 for unknown email, slow 401 for wrong password
const user = await store.findByEmail(input.email)
if (!user) return error(401)
if (!await verifyPassword(input.password, user.passwordHash)) return error(401)
```

The framework's `handleSignIn` always runs `verifyPassword` (with a dummy hash for the unknown-email case) so response times are uniform.

```ts
import { handleSignIn } from '@voltro/plugin-auth'
// handleSignIn internally (Effect-gen):
//   const user = yield* store.findByEmail(email)
//   const ok = user
//     ? yield* verifyPassword(password, user.passwordHash)
//     : (yield* verifyPassword(password, freshDecoyHash), false)
//   if (!user || !ok) return json(401, { error: 'invalid credentials' })
```

Use `handleSignIn` instead of rolling your own — the timing-oracle gap is the kind of subtle bug that hides for years. Note it returns a `401 HandlerResult`, it does not throw a domain error.

## Brute-force lockout

`plugin-auth` locks an account after repeated failed credential attempts, so password-spraying and credential-stuffing don't get unlimited guesses. After **5 failed attempts** (a wrong password — or, for MFA users, a wrong second-factor code) within **15 minutes**, sign-in for that email is refused with a `429 account_locked` (carrying a `retryAfterSeconds`) for **15 minutes**. A completed login clears the counter.

The counter is keyed by **email**, not user id, and it tracks unknown addresses too: a locked account and an unknown-but-hammered address respond identically, so the lock can't be turned into an existence oracle — the same reasoning as the timing-oracle defence above.

It is **on by default** — a security default you get for free. Tune or disable it per app:

```ts
authRoutesPlugin({
  store,
  lockout: {
    maxAttempts: 5,      // failed attempts before locking (default 5)
    windowSeconds: 900,  // counting window (default 15 min)
    lockSeconds: 900,    // lock duration (default 15 min)
  },
})
```

The counter lives in the `loginAttempts` table (contributed via `authTables`), so it appears automatically on your next `voltro db apply` / `voltro dev` boot. For lockout that holds across multiple nodes, back the store with Postgres (`postgresUserStore`) — the in-memory store is single-node.

## Rehashing on parameter bump

When the framework updates the default cost parameters, existing hashes stay valid — `verifyPassword` reads `N`/`r`/`p` from the stored hash string itself (they're encoded inline as `scrypt$<N>$<r>$<p>$…`). Rehash-on-verify ships: `needsRehash(stored)` reports whether a hash is below the current cost, and `verifyPasswordWithRehash(plaintext, stored)` returns `{ valid, rehash? }` — when the password matches an under-cost hash, `rehash` is a freshly-minted replacement. `handleSignIn` wires this through `UserStore.updatePassword`, so a user's stored hash silently strengthens on their next login, no forced reset and no backfill.

## Password policy

The framework doesn't enforce a policy at the hash layer — that's a UX decision. Enforce at the sign-up handler:

```ts
import { Schema } from 'effect'

const PasswordSchema = Schema.String.pipe(
  Schema.minLength(12),                              // OWASP minimum for non-2FA
  Schema.pattern(/[a-z]/),
  Schema.pattern(/[A-Z]/),
  Schema.pattern(/[0-9]/),
)
```

OWASP's current guidance: minimum length 8 (12 preferred), no upper-cap below 64, no required character classes if length ≥ 12. `handleSignUp` itself only enforces the 8-character floor; richer policy is yours to add at the route. Checking passwords against the [HIBP breach corpus](https://haveibeenpwned.com/API/v3) is a good idea — wire the k-anonymity range API into your sign-up route yourself; the plugin ships no HIBP helper.



---

<!-- source: en/authentication/sessions.md -->
## Sessions

_How sessions get issued, signed, and verified — the HMAC-SHA256 cookie payload format. For cookie attributes see Cookie security._

A Voltro session is a signed cookie. No server-side session store, no Redis dependency. The cookie *is* the session.

## Format

```
voltro:session=<base64url(payload)>.<base64url(signature)>
```

Where:

- `payload` is JSON `{ v, subject, exp, iat, kid? }`, e.g. `{ "v": 2, "subject": { "type": "user", "id": "user-id", "tenantId": "tenant-id" }, "exp": 1736294400, "iat": 1735689600 }`. `v` is the payload format version; `iat` drives the sliding-window renewal below; `kid` is the (non-secret) label of the signing key, stamped when signing with a keyed secret set.
- `signature` is HMAC-SHA256(payload, AUTH_SECRET)

**Why HMAC, not JWT?**

JWTs come with the `alg: 'none'` attack and a long history of header confusion. The framework's format is intentionally simpler: HMAC-SHA256, fixed algorithm, no header.

## The cookie carries identity, never authority

`subject` in the payload is a **`SubjectIdentity`** — the `Subject` union with `scopes` omitted at the schema level. A session cookie says *who* the caller is. What they may *do* is resolved on every request by [`auth.resolveScopes`](/docs/authentication/strategies).

This is not a style choice, it is the whole lifetime argument. Identity is settled once at sign-in and never changes. Authority changes the moment an admin removes a role — and a value signed into a 7-day cookie is frozen for 7 days, longer if the sliding renewal keeps re-signing it. Embedding scopes meant that narrowing someone's role had no effect on the session they already held: you could sign them out entirely, but you could not take one permission away.

Two consequences to know:

- **`issueSession` / `signSession` throw** when handed a subject that carries `scopes`. They do not strip them. A silently dropped scope is an authorization change with no error, no log line and no diff — you find it later, as "permissions randomly stopped working".
- **`readSession` / `verifySession` return a `SubjectIdentity`.** The absent field is the guarantee: nothing downstream can read authority out of a cookie, because the value it gets back has nowhere to keep it.

If you build a Subject yourself at login — a custom sign-in route, an SSO `onLogin` — mint the session from the identity and move the permissions into the resolver:

```ts
// before: authority frozen into the cookie for a week
issueSession({ ...identity, scopes: permissionsFor(user) }, secret)

// after: identity in the cookie, authority resolved per request
issueSession(identity, secret)
```

### Payload versioning — what happens to live sessions

`v` is required and pinned to the version this build mints. A payload without it, or with an older one, **fails to decode** — it does not fall back to a lenient read. A cookie from the previous format asserts authority, and honouring that assertion is exactly the defect being removed; authenticating someone under a contract the server no longer holds is worse than asking them to sign in.

So a framework upgrade that bumps the payload version ends every live session. Plan it like a secret rotation: users see the sign-in screen once. Their `sessions` rows are untouched.

## Issuing

```ts
import { issueSession } from '@voltro/plugin-auth'

// Positional args: (subject, secret, options?). Returns { value, setCookie }.
const { value, setCookie } = issueSession(
  subject,                            // an identity, e.g. subjectFromUser(user) — no scopes
  AUTH_CONFIG.secret,
  {
    ttlSeconds: 60 * 60 * 24 * 7,     // 7 days (default if omitted)
    domain:     '.your-product.com',  // flat on options, not nested
    secure:     true,                 // defaults to true
  },
)

// Set on the response
res.setHeader('set-cookie', setCookie)
```

`setCookie` is the full `Set-Cookie` string — `voltro:session=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800`. `value` is the raw signed cookie value (`<payload>.<sig>`) if you need it directly. `IssueSessionOptions` carries only `ttlSeconds`, `domain`, and `secure` — `HttpOnly` and `SameSite=lax` are hardcoded in the builder and not configurable.

## Reading

```ts
import { readSession } from '@voltro/plugin-auth'

const subject = readSession(req.headers.cookie, AUTH_CONFIG.secret)
// → { type: 'user', id, tenantId } | null   — a SubjectIdentity: no `scopes`
```

Returns `null` for:

- Missing cookie
- Tampered payload (signature mismatch)
- Expired session (`exp < now`)
- A payload from a superseded format version (see above)

## Clearing

```ts
import { clearSessionCookie } from '@voltro/plugin-auth'

res.setHeader('set-cookie', clearSessionCookie({ domain: '.your-product.com', secure: true }))
// voltro:session=; HttpOnly; Secure; …; Max-Age=0
```

`clearSessionCookie` takes the same `IssueSessionOptions` shape (`ttlSeconds` ignored; `domain` / `secure` honoured).

## Secret rotation

Zero-downtime rotation is env-driven and applies to **every** verify path — the framework's rpc auth chain, the plugin's `/auth/*` routes, and `readSession` itself:

1. Set the NEW secret as `VOLTRO_SESSION_SECRET` (current).
2. Move the OLD secret to `VOLTRO_SESSION_SECRET_PREVIOUS`. Verification now tries current first, then previous — no live session is invalidated. (`VOLTRO_SESSION_KID` / `VOLTRO_SESSION_KID_PREVIOUS` are optional non-secret labels; they default to `k0` / `k-previous`.)
3. Leave the window open for one max session lifetime, then drop the `_PREVIOUS` var. Cookies still signed with the old key stop verifying at that point — but they rarely exist by then, because any previous-key cookie that hits an authenticated `/auth/*` route (or `GET /auth/session`) is **re-issued under the current key** in the response.

Under the hood: `resolveSessionSecrets()` builds the `{ current, previous? }` keyed set from those env vars; `signSession` always signs with `current` (stamping its `kid`); `verifySessionKeyed` tries `current` then `previous`. The plugin's `AuthConfig` also accepts an explicit `secrets: SessionSecrets` when you'd rather not use env vars. The single-secret `resolveSessionSecret()` still exists for apps that don't rotate.

## Cookie attributes

The session cookie ships `HttpOnly`, `SameSite=lax`, and `Path=/` hardcoded; `Secure`, `Domain`, and `Max-Age` (from `ttlSeconds`) come from the options you pass `issueSession`. The full attribute checklist — and why `SameSite=lax` is the right default for cross-origin sign-in — lives on the [Cookie security](/docs/authentication/cookies) page.

## Multi-instance — no shared store needed

Because the session is the cookie + signature is deterministic from `(payload, secret)`, every api instance verifies independently. Scale to N replicas without a Redis cache; rolling deploys don't invalidate sessions.

For centralised revocation, the plugin writes a `sessions` row on every sign-in and exposes `handleListSessions` / `handleRevokeSession` / `handleRevokeAllOtherSessions` (mounted at `GET /auth/sessions` + `POST /auth/sessions/revoke` + `/sessions/revoke-others`). This is the device-management + "sign out other devices" surface — a stolen session can be killed without rotating the secret.

Revocation is enforced **at request time**: on every verify, the cookie's server-side session id (`metadata.sessionId`) is checked against the `sessions` table through a small in-process TTL cache (default **30 seconds**, tune via the plugin's `sessionRevocation.ttlMs`). Be honest about the window: a revocation is instant on the process that performed it (its cache entry is invalidated inline — sign-out kills the cookie immediately there) and takes effect within the cache window on every other replica. `POST /auth/sign-out` deletes the caller's session row, and a password-reset confirm revokes **all** of the user's sessions — in both cases a retained copy of the cookie stops authenticating within that window, days before its `exp`. The framework's rpc auth chain enforces the same check: `authRoutesPlugin` carries a pre-wired session strategy (sharing the same cache) that `voltro dev` / `voltro serve` slot into the chain automatically. One deliberate gap: a cookie minted by hand via `issueSession` without a `sessions` row carries no `sessionId` and stays purely stateless.

**Revoking a session and revoking a permission are two different operations, and both now take effect on a live session.** This section is the first: kill the row and the holder is signed out. The second is [`auth.resolveScopes`](/docs/authentication/strategies) — narrow the role and the caller keeps their session but loses the permission. The two windows are deliberately the same 30 seconds and both invalidate to zero the same way, so there is one number to reason about rather than a second one you discover later.

## When to use a longer / shorter lifetime

| Use case | Lifetime |
|---|---|
| Consumer SaaS, low-stakes | 30 days |
| B2B SaaS, sensitive data | 7 days *(default)* |
| Admin / billing dashboards | 1 day + idle timeout |
| Banking / health / compliance | 30 minutes + sliding window |

Sliding-window auto-renewal ships: the session payload carries an `iat`, and `verifySessionKeyed` returns a `renew` flag once the session crosses the renewal threshold (default 70% of its lifetime). The plugin acts on that signal on **authenticated `/auth/*` responses** (and whenever the cookie verified under the previous rotation key): the response carries a fresh `Set-Cookie` signed with the current key and the session's *original* lifetime, and the `sessions` row's `expiresAt` slides forward with it. `GET /auth/session` is the probe built for this — it returns the current subject plus `renewed: true|false`, so a client that pings it periodically keeps an active session alive while an idle one still expires on schedule. (Renewal is an HTTP-response mechanism — rpc frames over the WebSocket can't set cookies.)

## Verification details

```ts
// What readSession does:
// 1. Parse cookie → { payloadB64, sigB64 }
// 2. Compute expected = hmacSha256(payloadB64, secret)
// 3. Constant-time compare expected vs. sigB64
// 4. Decode payload (rejects a superseded payload version)
// 5. Check exp (reject if exp < now)
// 6. Return the decoded SubjectIdentity
```

All steps use constant-time comparisons to defeat timing attacks. Implementation lives in `@voltro/plugin-auth/session.ts` — read it for the full story.

## Browser-side considerations

The session cookie is `HttpOnly` — **the browser cannot read it from JS**. This is deliberate.

In React components on the SSR pass, you can decode the subject from the request via the layout (see [React on the web side](/docs/authentication/react)). On the client (post-hydration), there's no fresh cookie read — the layout's subject is the one you have until next page load.

That's enough for UI gating. For actions, the server verifies the cookie on every request — the client never needs to "have" the session, it just needs to send it (which the browser does automatically).



---

<!-- source: en/authentication/subject.md -->
## The Subject

_ctx.subject — what's on it, the anonymous fallback, and how to pattern-match on the type discriminator._

Every server-side executor receives `ctx.subject` — the typed identity of whoever is calling. It's always present (anonymous requests get an explicit `anonymous` subject), so app code never has to null-check before reading basic fields.

## Shape

```ts
type Subject =
  | { type: 'user';           id: string; tenantId: string;        scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
  | { type: 'apiKey';         id: string; tenantId: string;        scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
  | { type: 'serviceAccount'; id: string; tenantId: string;        scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
  | { type: 'system';         id: string; tenantId: null;          scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
  | { type: 'anonymous';      id: null;   tenantId: string | null }
```

`scopes` is the permission currency — the auth strategy that resolved the subject puts the computed scopes here, and `@voltro/plugin-rbac` compiles a caller's roles onto the same set. There is no `roles` field on the wire; roles are an authoring convenience that resolve to scopes.

The `type` discriminator narrows downstream fields:

```ts
if (ctx.subject.type === 'user') {
  // ctx.subject.id, tenantId are strings
}
if (ctx.subject.type === 'anonymous') {
  // ctx.subject.tenantId is string | null; id is null
}
```

## App metadata — `subjectFromUser(user, { metadata })`

`metadata` is the free-form slot the framework itself never reads. It is where a provider credential captured at login belongs — a plugin's `credentialsResolver` reads it back per request (`@voltro/plugin-atlassian` looks for `subject.metadata.jiraToken`, say), so nothing has to be re-fetched or stored server-side per call.

Pass it when you build the Subject:

```ts
import { subjectFromUser } from '@voltro/plugin-auth'

const subject = subjectFromUser(user, {
  memberships,                              // → metadata.memberships
  metadata: { jiraToken: atlassianPat },    // → metadata.jiraToken
})
```

The two merge — neither clobbers the other. **When a `memberships` key appears in both,** the dedicated `memberships` option wins: it is the typed input, and it is the one projected into the `{ tenantId, role }` shape `subjectMemberships()` and the tenant switcher read. Without the option, a `memberships` key inside `metadata` passes through unchanged. A Subject built with neither option has no `metadata` key at all.

Keys set this way survive the login paths: the sign-in / sign-up / magic-link / passkey handlers merge `sessionId` onto the existing slot, and the password strategy merges `provider` — they add, they don't replace. The one exception is naming a key `sessionId` or `provider` yourself; those two are overwritten by design.

**It survives a tenant switch too.** A switch rebuilds the Subject from the user record, so the built-in `/switch-tenant` route passes the caller's current `subject.metadata` through to `handleSwitchTenant` — a credential parked here keeps working after the user changes tenant. Calling `handleSwitchTenant` yourself? Pass `metadata` or the credential is dropped, and the symptom is unpleasant to diagnose: the user stays signed in while every call to the provider starts failing. `memberships` is deliberately *not* carried — it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there.

Two things not to put here. **Anything the caller could benefit from changing** — the slot rides the signed session cookie, so it is tamper-evident, but it is also stale by design: it reflects the moment of sign-in, not the current database. And **anything large** — it is re-serialised into every session cookie.

## Resolution

`AuthMiddleware` resolves a `Subject` on every request by running the [strategy chain](/docs/authentication/strategies) — `composeAuthStrategies` evaluates each strategy in order, first `matched` wins, first `failed` short-circuits to anonymous. A typical chain resolves, in order:

1. **Built-in password cookie** — `voltroPasswordStrategy` reads `voltro:session` and HMAC-verifies it. → `type: 'user'`.
2. **API key** — `apiKeyStrategy` from `@voltro/protocol/apikey` maps `Authorization: Bearer <prefix>_<token>` → a scoped `apiKey` subject. This ships today; add it to the `auth.strategies` chain in `app.config.ts`. → `type: 'apiKey'`.
3. **In-process system calls** — code running under `runAsSystem` (cron, workflows, backfills) carries a `system` subject; it's never produced from an inbound request. → `type: 'system'`.
4. **Anonymous fallback** — nothing matched. The composer's fallback reads the `x-tenant` header and produces `anonymousSubject(tenant)`. → `type: 'anonymous'`.

An anonymous request with `x-tenant: <id>` therefore carries that tenant on the subject, scoping reads on public tables. The header is unauthenticated — never trust it for writes.

## Common patterns

### Require sign-in

```ts
import { Schema } from 'effect'

class Unauthorised extends Schema.TaggedError<Unauthorised>()('Unauthorised', {}) {}

export default async (input, ctx) => {
  if (ctx.subject.type !== 'user') throw new Unauthorised()
  // ctx.subject.id is narrowed to string
}
```

### Require sign-in + specific tenant

```ts
import { assertOwnTenant } from '@voltro/plugin-multitenancy'

export default async (input, ctx) => {
  if (ctx.subject.type !== 'user') throw new Unauthorised()
  assertOwnTenant(input.tenantId, ctx.subject)
  // …
}
```

### Allow system OR user

```ts
const canTrigger = ctx.subject.type === 'user' || ctx.subject.type === 'system'
if (!canTrigger) throw new Unauthorised({})
```

### RBAC

`@voltro/plugin-rbac` builds on the subject's scopes. Roles **compile to scopes** — the plugin resolves the caller's roles, flattens them onto the resolved scope set, and the `permission()` handler guard checks it in one line:

```ts
import { permission } from '@voltro/plugin-rbac'

yield* permission(ctx, 'admin:full')   // Effect<void, ScopeError>
```

See [the RBAC plugin](/docs/plugins/rbac) for the full model.

## Anonymous tenant resolution

For public APIs where you want anonymous callers scoped to a tenant (multi-tenant marketing site, public listings), nothing extra to configure: when no strategy matches, `composeAuthStrategies`' default fallback reads the `x-tenant` header and yields `anonymousSubject(tenant)`:

```ts
import { anonymousSubject } from '@voltro/protocol'
// fallback output for `x-tenant: foo`:
//   { type: 'anonymous', id: null, tenantId: 'foo' }
```

Tables with `tenant()` then scope anonymous reads to `foo`. To *require* a tenant on anonymous callers — so a tenant-less request can't read across the whole DB on tables that aren't `tenant()`-scoped — pass `anonymousTenantRequired: true` to `composeAuthStrategies`:

```ts
const resolveSubject = composeAuthStrategies(strategies, {
  anonymousTenantRequired: true, // no x-tenant + no matched strategy → throws Unauthenticated
})
```

When set, an unmatched call with no `x-tenant` header throws `Unauthenticated` instead of yielding a null-tenant anonymous Subject. (Ignored when you supply a custom `fallback` — that function owns the decision.)

## Custom resolvers

For exotic auth setups (mTLS, custom JWTs from an upstream gateway) write a custom `AuthStrategy` and add it to the `auth.strategies` chain in `app.config.ts` — there is no `runtime.resolveSubject` field:

```ts
// app.config.ts
import { anonymousSubject } from '@voltro/protocol'

export default {
  type: 'api' as const,
  name: 'myApi',
  auth: {
    strategies: [
      {
        id: 'mtls',
        resolve: (input) => {
          const cn = input.headers['x-client-cert-cn']
          return cn
            ? { kind: 'matched', subject: { type: 'system', id: `mtls:${cn}`, tenantId: null } }
            : { kind: 'skip' }
        },
      },
    ],
  },
}
```

The built-in password strategy still runs first; your strategy adds to the chain. Most apps never need this.

## ctx.subject in subscriptions

`ctx.subject` is captured at subscribe-time. If the cookie expires mid-subscription:

- Subsequent mutations from the now-expired client fail with `Unauthorised`.
- The subscription itself **continues to stream** until the client reconnects or the server drops it.
- On reconnect, the new connection re-resolves the subject — anonymous if the cookie is gone.

The open stream isn't force-killed on expiry; the next write fails with `Unauthenticated` and the client can re-auth or reconnect. That gentler UX is the framework's behaviour.

## Anti-patterns

- **Reading `ctx.subject.id` without narrowing.** It's `string` on `user` / `apiKey` / `serviceAccount` / `system`, but `null` on `anonymous`. Narrow on `type` first.
- **Trusting `subject.tenantId` for writes.** It's read-only context. Always `assertOwnTenant(input.tenantId, ctx.subject)` for any write that takes a tenant.
- **Caching subjects across requests.** The subject is request-scoped; sessions expire, role membership changes. Resolve fresh each time.



---

<!-- source: en/authentication/strategies.md -->
## 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`](/docs/authentication/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](/docs/authentication/external-idp) 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?"*

```ts
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:

```ts
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:

```ts
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.

```ts
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](/docs/authentication/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:

```ts
// 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`](/docs/authentication/sessions) 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:

```ts
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:

```ts
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 and `voltro 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_SUBJECT` and is *not* put through your resolver. It already states its own authority, and it carries `tenantId: 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](/docs/authentication/sessions) 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:

```ts
// 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 membership
```

`VOLTRO_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:

```ts
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:

```ts
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](/docs/authentication/sessions) for how the cookie is minted.

## Writing your own strategy

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

```ts
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](/docs/authentication/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`:

```ts
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.

```ts
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](/docs/authentication/external-idp) — WorkOS, Kinde, Clerk, and the shared `jwtBearerStrategy`.



---

<!-- source: en/authentication/external-idp.md -->
## External identity providers

_WorkOS, Kinde, Clerk, Auth0, Supabase Auth, and generic OIDC as Voltro auth strategies — JWT/JWKS verification via the shared jwtBearerStrategy, claims-to-tenant mapping, and how they stack with password auth._

Voltro ships first-party [strategy](/docs/authentication/strategies) plugins for six identity providers, including a generic OIDC adapter that covers anything publishing an OpenID Connect discovery document:

| Package | Provider | Strategy id |
|---|---|---|
| `@voltro/plugin-auth-workos` | WorkOS AuthKit / SSO | `workos` |
| `@voltro/plugin-auth-kinde` | Kinde | `kinde` |
| `@voltro/plugin-auth-clerk` | Clerk | `clerk` |
| `@voltro/plugin-auth-auth0` | Auth0 | `auth0` |
| `@voltro/plugin-auth-supabase` | Supabase Auth (GoTrue) | `supabase` |
| `@voltro/plugin-auth-oidc` | Generic OIDC (Okta, Keycloak, Cognito, Azure AD, Google Workspace, …) | configurable via `id:` |

> **Looking for "Sign in with Google"?** That is a different plugin. Everything on this page is a *verifier* — it checks a JWT an enterprise IdP already issued. For consumer social login (Google / GitHub / Apple) where Voltro runs the whole redirect flow and mints your own session, use [`@voltro/plugin-auth-social`](/docs/plugins/auth-social). No identity vendor required.

Every plugin is a thin wrapper — ~70–110 lines each — over one shared engine: **`jwtBearerStrategy`**. They add nothing but provider-specific defaults (JWKS URL, cookie name, tenant-claim mapping). If your IdP isn't in the list AND doesn't expose a standard OIDC discovery document, point `jwtBearerStrategy` at its JWKS endpoint directly.

## The model: verify, don't redirect

These strategies do **not** run the OAuth redirect dance on the server. The provider's own SDK runs that in the browser and lands a signed **JWT** — either as a cookie (cookie mode) or an `Authorization: Bearer` header. The Voltro strategy's job is narrow and stateless:

```
request ─► extract token (Bearer or cookie)
        ─► verify signature against the provider's JWKS  (cached)
        ─► map verified claims → Subject (+ tenantId)
        ─► attach raw claims under metadata.claims
```

This means **you keep your `Subject` model and multi-tenant scope** — the IdP authenticates, but `tenantId` and the typed `Subject` stay the framework's, not the vendor's. No session is stored server-side for these strategies; the JWT *is* the session, re-verified per request (JWKS is cached, so steady-state verification is CPU-local).

> Security: the JWKS verifier allows asymmetric algorithms only (`RS256` / `ES256`, optionally `PS256` / `EdDSA`). `HS256` is intentionally rejected on the JWKS path — a shared-secret HMAC over a public JWKS flow is a downgrade vector. (Legacy / self-hosted Supabase that signs symmetrically is the one exception: it verifies through a separate, explicit shared-secret path — `supabaseStrategy({ jwtSecret })` — never the JWKS verifier. See [Supabase Auth](/docs/plugins/auth-supabase).)

There are **two ways** to put an external IdP in front of a Voltro app; this page's per-provider sections cover the first. In the **verify** model above, the IdP's JWT *is* the session — every request re-verifies it and no framework session is minted. The **second** model uses the IdP only for the *login step* — redirect to its hosted UI, take the callback, then mint your **own** `voltro:session` — so the IdP is an alternate front door, not the session authority. See [WorkOS SSO login](#workos-sso-login) below for a full example.

## Client: getting the token to the api (HTTP **and** WebSocket)

The strategy above only *verifies* — your browser still has to *send* the token on every request, over **both** transports the framework uses:

- **HTTP** (`POST /rpc`, loaders, `/auth/*`): the provider SDK's `fetch` (or your own) sends `Authorization: Bearer <token>` normally — nothing framework-specific.
- **WebSocket** (live subscriptions): a browser **cannot** set headers on a WS upgrade, so the token can't ride the handshake. Pass it to `mount()` instead — the framework attaches it to every rpc **message frame** (not the upgrade), where the same strategy reads it per call:

  ```ts
  // .framework/main.tsx (or your mount entry)
  mount(App, {
    apis: {
      api: {
        // A thunk is resolved fresh on every (re)connect, so a rotating
        // access token is pulled anew per connect, not frozen at first mount:
        headers: async () => ({
          authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token ?? ''}`,
        }),
      },
    },
  })
  ```

  This is **required when the api is a separate origin** (the common case — `api.example.com` vs your web origin): there is no shared cookie and no upgrade header, so without `headers` every subscription connects **anonymous** (the shell renders, but user-/tenant-scoped data stays empty). A static object works for non-rotating tokens; never hardcode a secret literal — it ships to the browser.

### Declaratively — `apis.<name>.authHeaders` in `app.config.ts`

Rather than hand-writing a `mount()` entry just to inject the thunk, declare it on the api in `app.config.ts`. The framework owns the client mount, the SSR-null case (the resolver runs browser-only — it never fires on the server), and the re-resolve on every reconnect; you supply only the token function:

```ts
// app.config.ts
export default {
  type: 'web' as const,
  apis: {
    api: {
      package: '@app/api',
      // Resolved fresh on every (re)connect — a rotating token is pulled anew:
      authHeaders: async () => ({
        authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token ?? ''}`,
      }),
    },
  },
}
```

`authHeaders` is a **function**, so it is imported from `app.config.ts` into the client bundle rather than serialized — the file must stay browser-safe (no `node:*` / server-only value imports; the env schema and other pure config are fine). It supersedes a static `headers` on the same api. This is the preferred form; reach for a hand-written `mount()` only when you need to wrap the tree in your own provider as well.

## WorkOS

```ts
import { workosStrategy } from '@voltro/plugin-auth-workos'

workosStrategy({
  clientId: process.env.WORKOS_CLIENT_ID!,   // client_01H… — builds the JWKS URL + audience
  // jwksUrl?:   https://api.workos.com/sso/jwks/<clientId>   (default)
  // issuer?:    https://api.workos.com                       (default)
  // cookieName?: 'wos-session'  (AuthKit cookie mode; null = header-only)
  // defaultTenantId?: fallback when claims expose no org_id
})
```

Tenant maps from `claims.org_id`. Verified claims arrive as `subject.metadata.claims` typed loosely as `WorkosClaims` (`sub`, `email`, `org_id`, `role`, `permissions`, …).

## WorkOS SSO login

The strategy above is the *verify* model — WorkOS' JWT is the session. `@voltro/plugin-auth-workos` also exports two primitives for the **other** model, where WorkOS runs the login and your app mints its **own** session — an alternate front door *alongside* password sign-in, not a replacement session authority. This is how **Voltro Cloud** offers a "Sign in with WorkOS SSO" button next to email/password.

- **`workosBeginLogin({ clientId, redirectUri })`** — returns `{ url, state, codeVerifier }`. `url` is the WorkOS hosted-login (AuthKit) URL to redirect the browser to; it always carries a freshly-minted CSRF `state` and a PKCE `code_challenge` (S256). Stash `state` and `codeVerifier` — the callback needs both.
- **`workosAuthenticateWithCode({ clientId, apiKey, code, state, expectedState, codeVerifier })`** — verifies the state in constant time, then exchanges the `?code=` from the callback for the authenticated `WorkosProfile` (`workosUserId`, `email`, `firstName`, `lastName`, `organizationId`). **Server-only** — it carries the WorkOS **API key** (the OAuth client secret).

Both are transport-thin (raw `fetch`, no `@workos-inc/node` dependency), so they drop into any app's own routing + provisioning.

> **`state` and PKCE are not optional, and the check is not yours to remember.** `state` used to be a parameter you could pass — which meant the default flow had no CSRF token at all. Without it, an attacker who gets a victim's browser to hit your callback with an authorization code *they* obtained logs the victim into the **attacker's** account. It is now minted for you on every call, and the state comparison lives *inside* `workosAuthenticateWithCode`: a generated `state` that nothing verifies is worse than none, because a code review and a screenshot of the authorize URL then both read as "CSRF is handled". PKCE (S256) rides along for the same reason — the authorization code travels through the address bar and the referrer chain, and without a verifier whoever captures it can redeem it first.

### The flow

```text
GET /auth/workos/login
   ─► const { url, state, codeVerifier } = workosBeginLogin({ clientId, redirectUri })
   ─► stash BOTH in short-lived HttpOnly cookies (CSRF nonce + PKCE verifier)
   ─► 302 → url

GET /auth/workos/callback?code=…&state=…
   ─► workosAuthenticateWithCode({ clientId, apiKey, code,
        state, expectedState: <cookie>, codeVerifier: <cookie> })
        → WorkosProfile   (throws WorkosStateMismatchError → 400 on a mismatch)
   ─► find-or-provision the local user + org   (SHARED with password signup)
   ─► issueSession(subject) → Set-Cookie: voltro:session=…
   ─► 302 → dashboard
```

The callback mints the **same** `voltro:session` as password sign-in, so everything downstream — the session strategy, org switching, audit — behaves identically. **WorkOS handles _authentication_; your app keeps _authorization_.** The IdP never becomes the session authority; it is simply another way in.

### Wiring the routes

Wrap the two routes in a small **server-only** plugin and add it to `plugins` in your api's `app.config`:

```ts
import { definePlugin } from '@voltro/protocol'
import { workosBeginLogin, workosAuthenticateWithCode, WorkosStateMismatchError } from '@voltro/plugin-auth-workos'
import { issueSession, resolveSessionSecret } from '@voltro/plugin-auth/session'

export const workosAuthPlugin = () =>
  definePlugin({
    name: 'workos-login',
    httpRoutes: [
      {
        method: 'GET',
        path: '/auth/workos/login',
        handle: async () => {
          const clientId = process.env.WORKOS_CLIENT_ID
          const redirectUri = process.env.WORKOS_REDIRECT_URI
          if (!clientId || !redirectUri) {
            return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
          }
          const { url, state, codeVerifier } = workosBeginLogin({ clientId, redirectUri })
          // SameSite=Lax, not Strict: the return from WorkOS is a top-level GET,
          // which Lax allows and Strict would drop — and a dropped cookie now
          // FAILS the login instead of silently skipping the check.
          const attrs = 'Path=/; Max-Age=600; HttpOnly; SameSite=Lax'
          return {
            status: 302,
            headers: {
              location: url,
              'set-cookie': [
                `workos-oauth-state=${state}; ${attrs}`,
                `workos-oauth-verifier=${codeVerifier}; ${attrs}`,
              ].join('\n'),
            },
            body: '',
          }
        },
      },
      {
        method: 'GET',
        path: '/auth/workos/callback',
        handle: async (req) => {
          const clientId = process.env.WORKOS_CLIENT_ID
          const apiKey = process.env.WORKOS_API_KEY
          if (!clientId || !apiKey) {
            return { status: 503, contentType: 'text/plain', body: 'WorkOS SSO is not configured.' }
          }
          const params = new URLSearchParams(req.query)
          const code = params.get('code')
          if (!code) return { status: 400, contentType: 'text/plain', body: 'Missing authorization code.' }
          let profile
          try {
            profile = await workosAuthenticateWithCode({
              clientId, apiKey, code,
              state:         params.get('state') ?? '',
              expectedState: readCookie(req.headers['cookie'], 'workos-oauth-state') ?? '',
              codeVerifier:  readCookie(req.headers['cookie'], 'workos-oauth-verifier') ?? '',
            })
          } catch (err) {
            // Distinct from "WorkOS said no" — worth alerting on separately.
            if (err instanceof WorkosStateMismatchError) {
              return { status: 400, contentType: 'text/plain', body: 'Invalid or missing OAuth state (possible CSRF).' }
            }
            return { status: 502, contentType: 'text/plain', body: 'WorkOS code exchange failed.' }
          }
          // find-or-provision runs the SAME path as password signup:
          const { userId, orgId } = await findOrProvisionUser(profile)
          const issued = issueSession({ type: 'user', id: userId, tenantId: orgId }, resolveSessionSecret())
          return {
            status: 302,
            headers: {
              location: '/dashboard',
              'set-cookie': `voltro:session=${issued.value}; Path=/; Max-Age=604800; SameSite=Lax`,
            },
            body: '',
          }
        },
      },
    ],
  })
```

Do **not** keep a hand-rolled `state !== cookieState` check beside this — two expressions of one rule, and the copy is the one that goes stale.

The **first** SSO login provisions the local user + org (the same routine password signup uses); later logins find the existing user by email. That's why the routes live in *your app*, not in the plugin — the provisioning and session model are yours; the plugin only supplies the reusable OAuth primitives. Match the `voltro:session` cookie's attributes (e.g. `Secure`, `HttpOnly`) to your app's existing session cookie.

### Configuration

All three variables are **optional** — the routes return **`503`** until they are set, so the app boots without WorkOS and you enable SSO by supplying credentials:

| Env var | Secret | Purpose |
|---|---|---|
| `WORKOS_CLIENT_ID` | no | WorkOS Client ID (`client_…`) for the hosted login. |
| `WORKOS_API_KEY` | **yes** | WorkOS API key (`sk_…`) — the OAuth client secret used in the code exchange. |
| `WORKOS_REDIRECT_URI` | no | The callback URL, **registered in the WorkOS dashboard** — e.g. `https://app.voltro.cloud/auth/workos/callback`. |

Add a "Sign in with WorkOS SSO" link on your sign-in page pointing at `GET /auth/workos/login`, and the round trip runs end to end.

## Kinde

```ts
import { kindeStrategy } from '@voltro/plugin-auth-kinde'

kindeStrategy({
  issuer: 'https://yourcompany.kinde.com',   // required
  // jwksUrl?:   <issuer>/.well-known/jwks    (default)
  // cookieName?: 'kinde_access_token'        (null = header-only)
  // defaultTenantId?: for single-tenant setups
})
```

Tenant maps from `claims.org_code`, falling back to `claims.org_codes[0]`.

## Clerk

```ts
import { clerkStrategy } from '@voltro/plugin-auth-clerk'

clerkStrategy({
  frontendApi: 'https://clerk.yourapp.com',  // Clerk Frontend API host
  // jwksUrl?:   <frontendApi>/.well-known/jwks.json  (default)
  // issuer?:    <frontendApi>                          (default)
  // cookieName?: '__session'  (Clerk's hardcoded cookie name)
  // defaultTenantId?: when no org_id claim is present
})
```

Tenant maps from `claims.org_id` (Clerk's "Organizations" feature). Clerk's default `__session` tokens carry no `aud` claim, so the audience check is off unless you configure one.

## Auth0

```ts
import { auth0Strategy } from '@voltro/plugin-auth-auth0'

auth0Strategy({
  domain:   'acme.us.auth0.com',                    // required — builds JWKS URL + issuer
  audience: 'https://api.acme.com',                 // your API identifier
  // jwksUrl?:     https://<domain>/.well-known/jwks.json  (default)
  // issuer?:      https://<domain>/                       (default — TRAILING SLASH MATTERS)
  // tenantClaim?: 'https://voltro.dev/tenant'            (default namespaced URN)
  // cookieName?:  null                                    (header-only by default)
  // defaultTenantId?: fallback for single-tenant Auth0 setups
})
```

Tenant maps from a namespaced custom claim (Auth0's rules require non-standard claims to live under a URN). Use a custom Auth0 Action to copy your app metadata into the namespaced claim, or configure `tenantClaim` to an unnamespaced field that already exists in your token.

## Supabase Auth

```ts
import { supabaseStrategy } from '@voltro/plugin-auth-supabase'

supabaseStrategy({
  projectRef: 'abcdef',                              // builds the hosted JWKS URL
  // OR self-hosted GoTrue:
  // jwksUrl: 'https://my-supabase.example.com/auth/v1/.well-known/jwks.json',
  // issuer:  'https://my-supabase.example.com/auth/v1',

  // tenantClaim?: 'app_metadata.tenant_id'           (default, dotted path)
  // audience?:    'authenticated'                    (default — Supabase's role)
  // defaultTenantId?: for single-tenant deployments
})
```

Tenant maps from `app_metadata.tenant_id` by default (a server-controlled blob; users can't forge it). Apps that query tenants via `user_metadata` set `tenantClaim: 'user_metadata.org'` — same syntax, dotted-path resolution. `cookieName` defaults to `null` (header-only); Supabase's JS SDK stores the access token at `sb-<projectRef>-auth-token` as a JSON payload, so most Voltro apps just consume the Bearer header and leave the cookie path off.

## Generic OIDC (Okta, Keycloak, Cognito, Azure AD, …)

For any IdP that publishes an OpenID Connect discovery document, `@voltro/plugin-auth-oidc` discovers the JWKS at boot — no per-provider package needed:

```ts
import { oidcStrategy } from '@voltro/plugin-auth-oidc'

oidcStrategy({
  id:       'okta',                                  // stable id for logs + metadata.provider
  issuer:   'https://acme.okta.com',
  audience: 'api://acme',
  tenantClaim: 'acme/tenant_id',                     // your provider's tenant claim
})
```

The strategy fetches `${issuer}/.well-known/openid-configuration` on the first request, extracts `jwks_uri`, and caches the discovery document for the lifetime of the process. Pass `jwksUrl` explicitly to skip the discovery roundtrip entirely (slightly faster cold-start, no behavioural difference). Pass `discoveryUrl` if your provider hosts the document at a non-standard path.

Common config combinations:

| Provider | `issuer` | `tenantClaim` | Notes |
|---|---|---|---|
| Okta | `https://<org>.okta.com/oauth2/default` | `acme/tenant_id` (custom claim) | Use the "default" authorization server unless you've set up a custom one. |
| Keycloak | `https://<host>/realms/<realm>` | `realm_access.tenant` | Add a custom claim mapper to surface the tenant. |
| Cognito | `https://cognito-idp.<region>.amazonaws.com/<userPoolId>` | `custom:tenant_id` | Cognito prefixes custom claims with `custom:`. |
| Azure AD | `https://login.microsoftonline.com/<tenantId>/v2.0` | `tid` | `tid` IS the Azure tenant — usually you map it 1:1 to Voltro's `tenantId`. |
| Google Workspace | `https://accounts.google.com` | `hd` | `hd` is the hosted-domain claim. |

## Stacking with password auth

Strategies compose — you can accept *both* your own password sessions and an external IdP during a migration, or per surface:

```ts
import { composeAuthStrategies } from '@voltro/protocol'
import { voltroPasswordStrategy } from '@voltro/plugin-auth'
import { clerkStrategy } from '@voltro/plugin-auth-clerk'

const resolve = composeAuthStrategies([
  voltroPasswordStrategy(),                            // existing users on our cookie
  clerkStrategy({ frontendApi: process.env.CLERK_FRONTEND_API! }),  // new users on Clerk
])
```

First match wins, so existing password sessions keep working while new sign-ups flow through Clerk — no big-bang cutover. See [strategies](/docs/authentication/strategies) for chain semantics (and why a `failed` verdict stops the chain rather than falling through).

## Reading provider claims in handlers

The verified JWT claims ride along on the Subject so handlers can use provider-specific data without the framework knowing the provider's shape:

```ts
const execute = async (input, ctx) => {
  const s = ctx.subject
  if (s.metadata?.provider === 'workos') {
    const claims = s.metadata.claims as WorkosClaims
    if (!claims.permissions?.includes('billing:write')) {
      throw new Unauthenticated({ reason: 'missing billing:write' })
    }
  }
  // …
}
```

`metadata.provider` is the discriminator; `metadata.claims` is the raw verified payload. The framework never reads either — it's a passthrough slot, so adding a provider never changes the `Subject` type.

## Rolling your own provider

Three escape hatches, in order of effort:

1. **`oidcStrategy`** from `@voltro/plugin-auth-oidc` — for any IdP with a standard OIDC discovery document. Most modern providers (Okta, Keycloak, Cognito, Azure AD, Google) work with this; configure `id`, `issuer`, `audience`, `tenantClaim` and you're done.
2. **`jwtBearerStrategy`** from `@voltro/protocol/jwt` directly — for non-OIDC bearer-token providers, custom audiences, or when you want absolute control over the JWKS URL + verification rules:

   ```ts
   import { jwtBearerStrategy } from '@voltro/protocol/jwt'

   const custom = jwtBearerStrategy({
     id: 'my-idp',
     jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
     issuer:  'https://idp.example.com',
     audience: 'https://api.yourapp.com',
     cookieName: null,                                  // Bearer header only
     tenantIdFromClaims: (claims) => (claims['org_id'] as string) ?? null,
   })
   ```
3. **Custom AuthStrategy implementation** — for non-JWT auth (cookies that resolve via a DB lookup, mTLS, …). See [strategies](/docs/authentication/strategies) for the contract.

Returning `null` from `tenantIdFromClaims` is a `failed` verdict — the strategy owns the request but can't satisfy the tenant invariant, so it won't silently produce a tenant-less Subject.



---

<!-- source: en/authentication/handlers.md -->
## HTTP handlers

_handleSignIn, handleSignUp, handleSignOut — the HTTP-layer functions that turn form fields into session cookies._

`@voltro/plugin-auth` exposes three HTTP-shape handlers. They take request bits + a user store + auth config, and return response bits (status, body, cookie, redirect). They're transport-agnostic — wire them into Node's `http`, Express, Hono, anything.

## handleSignIn

```ts
import { handleSignIn } from '@voltro/plugin-auth'

const result = await handleSignIn(
  {
    email:           fields.email,
    password:        fields.password,
    redirectAfter:   true,             // 302 redirect vs JSON response
  },
  userStore,
  AUTH_CONFIG,
)

// result: {
//   status:      number,
//   body:        string,
//   contentType: string,
//   setCookie?:  string,
//   location?:   string,
// }
```

What it does:

1. `userStore.findByEmail(email)` — fetch the user (or pretend, see [timing oracle](/docs/authentication/passwords#timing-oracle-defence)).
2. `verifyPassword(password, user.passwordHash)` — always run, even on unknown email, and even when the user exists but has **no** stored hash.
3. On success: `issueSession(subject, config.secret, {...})` → set the `Set-Cookie` header.
4. If `redirectAfter` is true (form POST): 302 to `AUTH_CONFIG.successRedirect`.
5. If `redirectAfter` is false (XHR / fetch): 200 + JSON `{ ok: true, subject }`.

Failures return a 401 `HandlerResult` with a generic `{ error: 'invalid credentials' }` body — no email-existence disclosure. That includes an account with no `passwordHash` at all (SSO-only, magic-link-only, passkey-only, provider PAT): the password strategy **refuses** it, taking the same branch as an unknown email — same decoy scrypt, same status, same body, no cookie. An absent hash is never "nothing to compare, let them in", and the response does not reveal which accounts are password-less. The handlers encode failures as 4xx/5xx `HandlerResult`s rather than throwing domain errors across the wire.

## handleSignUp

```ts
const result = await handleSignUp(
  { email, password, redirectAfter: true },
  userStore,
  AUTH_CONFIG,
)
```

What it does:

1. Reject empty email/password (400) and passwords under 8 characters (400). That length floor is the only built-in policy; richer rules belong in your sign-up route, see [Passwords](/docs/authentication/passwords#password-policy).
2. `userStore.findByEmail(email)` — if exists, return 409.
3. `hashPassword(password)`.
4. `userStore.insert({ email, passwordHash, tenantId: defaultTenantId })`.
5. `issueSession(...)` + 302 redirect or 200 JSON `{ ok: true, subject }`. Any failure in this body collapses to `500 { error: 'signup_failed' }`.

The new user is added to the `defaultTenantId` from `AuthConfig`. For multi-tenant invite flows where the tenant is decided by an invite token, write a custom handler — `handleSignUp` is the convenient default.

## handleSignOut

```ts
const result = handleSignOut(AUTH_CONFIG)

// result: {
//   status:    302,
//   setCookie: 'voltro:session=; Max-Age=0; …',
//   location:  '/',     // config.successRedirect ?? '/'
// }
```

Synchronous — just emits an expired-cookie header + a redirect. The cookie kills any subsequent verifier; no server-side state to update.

## Wiring into Node http

```ts
// apps/api/index.ts
import { createServer } from 'node:http'
import { Effect } from 'effect'
import {
  handleSignIn, handleSignUp, handleSignOut,
  postgresUserStore, type AuthConfig,
} from '@voltro/plugin-auth'

const AUTH_CONFIG: AuthConfig = {
  secret:           process.env.AUTH_SECRET!,
  defaultTenantId:  'acme',
  cookieSecure:     process.env.NODE_ENV === 'production',
  cookieDomain:     '.your-product.com',                  // optional
  successRedirect:  process.env.AUTH_SUCCESS ?? '/dashboard',
}

// postgresUserStore takes an `@effect/sql` SqlClient and returns a
// UserStore synchronously — the caller owns the client's lifecycle.
const store = postgresUserStore(sql)

createServer(async (req, res) => {
  const url = req.url ?? ''
  const fields = await parseFormBody(req)

  if (req.method === 'POST' && url === '/auth/signin') {
    const r = await Effect.runPromise(handleSignIn(
      { ...fields, redirectAfter: true },
      store,
      AUTH_CONFIG,
    ))
    res.statusCode = r.status
    if (r.setCookie) res.setHeader('set-cookie', r.setCookie)
    if (r.location) res.setHeader('location', r.location)
    res.setHeader('content-type', r.contentType ?? 'text/plain')
    res.end(r.body)
    return
  }

  // … signup, signout, plus your own queries
}).listen(4100)
```

## With CORS + credentials

If your web app + api are on different origins, the form POST works (form submission is cross-origin-allowed) but XHR / fetch needs:

- Server: `Access-Control-Allow-Credentials: true` + exact-origin allow-list.
- Client: `fetch(url, { credentials: 'include' })`.
- Cookies: SameSite is hardcoded `lax` in the session builder (it is not an `AuthConfig` field) — which is exactly what cross-origin form POST sign-in needs. `strict` would block them, but the plugin never emits `strict`.

For single-origin deploys (one Caddy / nginx in front of both), none of this matters — same-origin cookies always flow.

## How the handlers surface failures

The handlers do NOT throw domain errors across the wire — sign-in / sign-up encode failures as 4xx/5xx `HandlerResult`s:

| Result | When |
|---|---|
| `400 { error: 'email and password required' }` | missing field |
| `400 { error: 'password must be at least 8 characters' }` | sign-up, password too short |
| `401 { error: 'invalid credentials' }` | sign-in: email not found OR password mismatch (uniform timing) |
| `409 { error: 'email already registered' }` | sign-up: email already in use |
| `500 { error: 'signup_failed' }` | any uncaught failure inside `handleSignUp` (e.g. a hash or insert error) |

The *tagged* errors that the lower-level primitives raise — and that you can `Effect.catchTag` if you compose them yourself — are `PasswordEmptyError`, `PasswordHashError` (from `@voltro/plugin-auth/password`), `UserAlreadyExistsError`, `UserNotFoundError` (from the `UserStore`), and `TotpVerifyError` (from the MFA path). They extend `Data.TaggedError` / `Schema.TaggedError`, so they carry typed payloads.

## Customising the response shape

The default body is JSON for XHR + redirects for form POST. To customise:

```ts
const r = await handleSignIn({ ... }, store, AUTH_CONFIG)
// r.status, r.setCookie, r.location are stable.
// Replace r.body with your own JSON / HTML / template render.
```

For instance, returning HTML on the sign-in page itself (no redirect) for a "you're signed in — refresh to continue" flow.

## MFA / TOTP handlers (shipped)

MFA is enforced at sign-in, not just enrolled. `handleSignIn` reads `user.mfaEnrolledAt`: for an enrolled user it does **not** issue a session — it mints a single-use, short-lived pending token (hash stored in `authTokens`) and returns `{ ok: true, mfaRequired: true, pendingToken }`. The second factor is completed separately:

- `handleMfaVerify({ pendingToken, code? , recoveryCode? }, store, config)` — redeems the pending token atomically (single-use), verifies the 6-digit TOTP `code` against the stored secret (or a single-use `recoveryCode` as the lost-authenticator fallback), and only then issues the real session via the same `issueSession` path as password sign-in (so rotation + revocation apply). A wrong code is `401`, and the challenge is consumed regardless — a guess can't be retried against the same token.

Enrolment is a separate ceremony (mount its routes with the plugin's `mfa: { issuer }` config):

- `handleMfaEnrollStart({ userId, issuer, accountName }, store)` — generates a TOTP secret, stashes it via `store.setMfaSecret`, returns `{ secret, otpauthUrl }` so the dashboard can render the QR.
- `handleMfaEnrollVerify({ userId, code }, store)` — verifies the first code; on success marks the user enrolled via `store.markMfaEnrolled` **and returns a one-time batch of `recoveryCodes`** (shown once, stored hashed via `store.replaceRecoveryCodes`).
- `handleMfaRegenerateRecoveryCodes({ userId }, store)` — wipes + reissues the recovery-code set.
- `handleMfaUnenroll({ userId }, store)` — clears the secret via `store.clearMfa` and wipes the recovery codes.

They return the same `Effect<HandlerResult>` shape as the others. The `UserStore` MFA methods (`setMfaSecret` / `markMfaEnrolled` / `clearMfa` / `replaceRecoveryCodes` / `consumeRecoveryCode` / `countRecoveryCodes`) back them; both `memoryUserStore` and `postgresUserStore` implement them.

## Magic link + password reset

`handleMagicLinkRequest` / `handleMagicLinkConsume` and `handlePasswordResetRequest` / `handlePasswordResetConfirm` round out the passwordless + recovery flows. The request handlers always return `202` (no account-enumeration); they mint a single-use, hashed, expiring token (`@voltro/plugin-auth/tokens`), persist its hash via `store.insertToken`, and call the injected `config.sendEmail` hook to deliver the link. The consume/confirm handlers redeem the token atomically via `store.consumeToken` (single-use guard) — `handleMagicLinkConsume` issues a session, `handlePasswordResetConfirm` sets the new hashed password and revokes every existing session.

```ts
const requested = await Effect.runPromise(
  handleMagicLinkRequest({ email }, store, { ...config, appBaseUrl: 'https://app.example.com', sendEmail }),
)   // 202 regardless of whether `email` exists
```

## Sessions, memberships, switch-tenant

`handleListSessions` / `handleRevokeSession` / `handleRevokeAllOtherSessions` back the device-management UI (`handleSignIn` writes a `sessions` row on every login). `handleListMemberships` + `handleSwitchTenant` drive multi-tenant switching — `handleSwitchTenant` validates membership via `store.membershipRole`, re-issues the cookie with the new active tenant, and rebinds the live connection's Subject when a `clientId` + `rebind` callback are supplied.

## CSRF + passkeys

`handleCsrf` issues a signed double-submit token (`voltro:csrf` cookie + JSON body). The passkey ceremony handlers (`handlePasskeyRegisterOptions` / `…RegisterVerify` / `…AssertOptions` / `…AssertVerify`) live in `@voltro/plugin-auth` and verify challenge, origin, rpId hash, signature, and a strictly-increasing counter. You don't mount any of these by hand — `authRoutesPlugin()` mounts the whole set under `/auth`.

## See also

- [Auth plugin overview](/docs/plugins/auth) — `authRoutesPlugin()`, the mounted route table, passkeys, rotation
- [Auth strategies](/docs/authentication/strategies) — how `AuthMiddleware` resolves `ctx.subject` from a request
- [External identity providers](/docs/authentication/external-idp) — stack an IdP alongside these password handlers
- [User stores](/docs/authentication/user-stores) — the `UserStore` the handlers write through
- [Sessions](/docs/authentication/sessions) — the cookie `issueSession` mints

- [Auth strategies](/docs/authentication/strategies) — how `AuthMiddleware` resolves `ctx.subject` from a request
- [External identity providers](/docs/authentication/external-idp) — stack an IdP alongside these password handlers
- [User stores](/docs/authentication/user-stores) — the `UserStore` the handlers write through
- [Sessions](/docs/authentication/sessions) — the cookie `issueSession` mints



---

<!-- source: en/authentication/user-stores.md -->
## User stores

_memoryUserStore, postgresUserStore, and writing your own UserStore against a different backend._

A `UserStore` is the interface between `@voltro/plugin-auth` and your user data. The plugin ships two implementations + the interface so you can plug in your own.

## The interface

Every method returns an **Effect**, not a Promise:

```ts
import type { UserStore } from '@voltro/plugin-auth'
import { Effect } from 'effect'

interface UserStore {
  findByEmail: (email: string) => Effect.Effect<UserRecord | null>
  findById:    (id: string)    => Effect.Effect<UserRecord | null>
  insert:      (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>
  // MFA / TOTP enrolment — back the handlers in HTTP handlers § MFA.
  setMfaSecret:    (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>
  markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
  clearMfa:        (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
}

interface UserRecord {
  readonly id: string
  readonly email: string
  readonly passwordHash?: string | null   // absent = this account has no password
  readonly tenantId: string
  readonly createdAt: Date
  readonly mfaSecret?: string | null      // base32 TOTP secret; null = not enrolled
  readonly mfaEnrolledAt?: Date | null    // first successful verify; null until enrolled
}
```

`passwordHash` is **optional** — not every identity has a password. An SSO-only, magic-link-only, passkey-only or provider-PAT app simply omits it, and the `users` table column is nullable to match. Do not invent a placeholder: a fake hash is a real value sitting in the column a verifier compares against, which is strictly worse than storing nothing. `null`, `undefined` and `''` all mean "no password".

`handleSignIn` refuses a password sign-in for such an account the same way it refuses an unknown email — it still burns a decoy scrypt and returns the identical 401 with no cookie — so the response reveals neither that the account exists nor that it is password-less. Assigning a hash later (password reset, set-password) promotes the account to a password user normally. `createdAt` stays required; every store can supply it, and `insert` takes `Omit<UserRecord, 'createdAt'>` so you never pass one yourself.

There is no `updatePasswordHash` method and no `updatedAt` field. The interface is deliberately small. Add your own fields (display name, avatar URL, locale) on a sibling table joined by `userId` — keep the auth-critical fields in the auth table.

## memoryUserStore

For dev + tests:

```ts
import { memoryUserStore, hashPassword } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const store = memoryUserStore()
await Effect.runPromise(Effect.gen(function* () {
  yield* store.insert({
    id:           'usr_demo',
    email:        'demo@example.com',
    passwordHash: yield* hashPassword('voltro-demo-2026'),
    tenantId:     'acme',
  })
}))
```

`memoryUserStore(seed?)` accepts an optional seed array of `UserRecord`s.

Lives in-process. Restart = data gone. Use it for CI smoke tests + the cloud `/demo` query.

## postgresUserStore

For production:

```ts
import { postgresUserStore } from '@voltro/plugin-auth'
import { SqlClient } from '@effect/sql'
import { Effect } from 'effect'

// postgresUserStore is SYNCHRONOUS — it takes an @effect/sql SqlClient and
// returns a UserStore directly. No await, no { url }, no internal pool.
// The caller owns the SqlClient's lifecycle.
const program = Effect.gen(function* () {
  const sql   = yield* SqlClient.SqlClient
  const store = postgresUserStore(sql)
  // … use store …
})
```

What it does:

- Runs its queries through the **provided** `SqlClient` — it does NOT open or own a connection.
- Reads + writes the `users` table from `@voltro/plugin-auth/schema`.
- Uses parameterised queries — no SQL injection.
- Maps the Postgres `23505` unique-violation on `email` to a typed `UserAlreadyExistsError` on insert; other SQL errors become defects.

The schema you need is in your `database/schema.ts`:

```ts
import { usersTable } from '@voltro/plugin-auth/schema'
export const users = usersTable
```

Run `voltro migrate` after adding this — the table + indexes (`email` UNIQUE, `tenantId` btree) get created.

## Custom store — example: external IdP

If you sync users from an external identity provider, write a store that reads from your provider's API + your local cache:

```ts
import type { UserStore } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const okta = (): UserStore => ({
  findByEmail: (email) =>
    Effect.promise(async () => {
      const oktaUser = await fetch(`https://okta.acme.com/api/v1/users?email=${email}`)
        .then((r) => r.json())
      if (!oktaUser) return null
      return {
        id:           `okta:${oktaUser.id}`,
        email:        oktaUser.profile.email,
        tenantId:     oktaUser.profile.tenantId,
        createdAt:    new Date(oktaUser.created),
      }
    }),
  // … the remaining methods (findById, insert, setMfaSecret,
  //   markMfaEnrolled, clearMfa) all return Effects too …
})
```

For an external IdP you usually wouldn't use `handleSignIn` at all — sign-in happens at the IdP and a [strategy](/docs/authentication/strategies) (e.g. the Okta path of `@voltro/plugin-auth-oidc`) verifies the resulting JWT. A custom `UserStore` only matters if you also need to persist a local mirror.

## Custom store — example: scoped extension

If you want to keep `postgresUserStore` for the auth-critical bits but augment with your own fields:

```ts
import { postgresUserStore } from '@voltro/plugin-auth'
import type { UserStore } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const base = postgresUserStore(sql)   // sql: a provided SqlClient

const extended: UserStore = {
  ...base,
  insert: (user) =>
    base.insert(user).pipe(
      Effect.tap((inserted) =>
        // Side-effect: also create a profile row
        Effect.promise(() =>
          ctx.store.insert('profiles', { userId: inserted.id, displayName: '' }),
        ),
      ),
    ),
}
```

Wrap don't fork — the base store is maintained by the framework; you keep your extensions in your code.

## Tenant on sign-up

`UserStore.insert(record)` requires `tenantId`. The handler picks it from:

1. `record.tenantId` if you pass it explicitly (custom handler with an invite flow).
2. `AuthConfig.defaultTenantId` otherwise.

For real apps, you want one of:

- **Per-invite tenant** — store an `invites` table; sign-up consumes an invite token that names the tenant.
- **One-tenant-per-email-domain** — derive tenant from email domain on sign-up.
- **Self-serve tenant create** — sign-up creates a new tenant + the user is its admin.

The framework doesn't pick for you; the auth plugin ships the primitives + you wire the policy.

## Anti-patterns

- **Storing the password hash with the user-facing record.** Even with `HttpOnly` cookies, accidentally returning `passwordHash` from a query is a disaster. Use a separate "PublicUser" type for everything that crosses the wire.
- **Using `tenantId: undefined` for "global" users.** Voltro doesn't model global users — every user belongs to exactly one tenant. For cross-tenant admins, use a separate `staff` table or the `system` subject.
- **Auth + business data in the same table.** Keep `users` minimal (email, hash, tenantId, timestamps). Profile data, settings, etc. go in joined tables.



---

<!-- source: en/authentication/react.md -->
## React on the web side

_SubjectProvider, useSubject, useOptionalSubject, RequireAuth — the client-side auth surface from @voltro/plugin-auth/web._

`@voltro/plugin-auth/web` is the client-side surface. It never imports `node:crypto`, so it's safe to bundle into the browser. The cookie is verified on the server; this package only provides React glue for "what does the current user look like".

## SubjectProvider

Mount once near the top of your layout tree:

```tsx
import { SubjectProvider } from '@voltro/plugin-auth/web'
import { useServerRequest } from '@voltro/web'
import { decodeSubjectFromRequest } from '../lib/auth'

export default function Layout({ children }) {
  const req = useServerRequest()         // SSR snapshot of cookies + headers
  const subject = decodeSubjectFromRequest(req)
  return <SubjectProvider subject={subject}>{children}</SubjectProvider>
}
```

`subject` can be `null` (anonymous) or a real `Subject` (signed in). The provider just stores it in a React context — no async work, no transport.

## useSubject

```tsx
import { useSubject } from '@voltro/plugin-auth/web'

const Component = () => {
  const subject = useSubject()           // throws if no SubjectProvider OR signed out
  return <p>Hi {subject.id}</p>
}
```

Throws when:

- No `<SubjectProvider>` is mounted (dev mistake).
- The mounted provider has `subject={null}` (you're calling `useSubject` from outside a guarded subtree).

Use inside `<RequireAuth>` blocks or pages you've already gated.

## useOptionalSubject

```tsx
import { useOptionalSubject } from '@voltro/plugin-auth/web'

const Header = () => {
  const subject = useOptionalSubject()   // Subject | null
  return subject
    ? <UserMenu subject={subject} />
    : <SignInButton />
}
```

The right hook for "what do I render based on signed-in vs. not".

## RequireAuth

A render-gate component:

```tsx
import { RequireAuth } from '@voltro/plugin-auth/web'

const Dashboard = () => (
  <RequireAuth fallback={<RedirectToSignIn />}>
    {(subject) => (
      <div>
        <h1>Hi {subject.id}</h1>
        <Projects tenantId={subject.tenantId} />
      </div>
    )}
  </RequireAuth>
)
```

- Children can be plain JSX or a `(subject: Subject) => ReactNode` render-prop. The render-prop gives you typed Subject access without an extra `useSubject` call.
- `fallback` defaults to `null` — pass a component to redirect / show a sign-in form.

## Decoding the subject on SSR

The session cookie value is opaque + signed — on the server you'd verify with HMAC + `AUTH_SECRET`. On the client (which is what the framework's SSR usually serves at request time), you have two choices:

### Option A — trust-then-verify

The web app reads the cookie payload via base64 decode only, gets the subject for UI purposes. EVERY action that mutates state goes back to the api which re-verifies the signature.

```ts
// web/src/lib/auth.ts
export const decodeSubjectFromRequest = (req: ServerRequest | null): Subject | null => {
  if (!req) return null
  const cookie = req.cookies['voltro:session']
  if (!cookie) return null
  const [payloadB64] = cookie.split('.')
  if (!payloadB64) return null
  try {
    // The payload is `{ subject, exp }` — the whole Subject round-trips
    // inside the cookie, so read `payload.subject`, not a `sub` claim.
    const payload = JSON.parse(atob(payloadB64))
    return payload.subject ?? null
  } catch {
    return null
  }
}
```

The downside: a tampered cookie shows the wrong UI. The upside: the api validates on every real action so the worst case is "wrong avatar in the header".

This is the cloud dashboard's pattern — keep `node:crypto` out of the web bundle.

### Option B — verify on the api, ship the decoded subject

The api exposes a `/auth/me` endpoint that decodes the cookie + returns the verified Subject. The web app's loader calls it.

```tsx
export const loader = async ({ headers }) => {
  const res = await fetch(`${API}/auth/me`, { headers: { cookie: headers.cookie } })
  return res.ok ? await res.json() : null
}
```

The downside: one extra request per page load. The upside: cryptographically verified subject.

For high-stakes apps (banking, healthcare), use Option B. For typical SaaS, Option A + server-side verification on actions is fine.

## Sign-out button

Always a form POST — `HttpOnly` cookies mean JS can't clear them, only the server (via a `Set-Cookie: …; Max-Age=0`).

```tsx
<form action={`${API}/auth/signout`} method="post">
  <button type="submit">Sign out</button>
</form>
```

`SameSite=lax` cookies flow on form POSTs even cross-origin.

## Sign-in form

```tsx
<form action={`${API}/auth/signin`} method="post">
  <input name="email" type="email" required />
  <input name="password" type="password" required />
  <button type="submit">Sign in</button>
</form>
```

The api 302-redirects to `AUTH_CONFIG.successRedirect` on success. The next page load reads the cookie via `<SubjectProvider>` + your auth-gated UI lights up.

For inline-validation / "shake on bad password" UX, use a fetch-based form + the JSON response variant of `handleSignIn`.

## Switching tenants

A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships — read them on the client with `subjectMemberships(subject)` to render a tenant switcher:

```tsx
import { useSubject } from '@voltro/plugin-auth/web'
import { subjectMemberships } from '@voltro/plugin-auth'

const TenantSwitcher = () => {
  const subject = useSubject()
  const memberships = subjectMemberships(subject)
  const switchTo = async (tenantId: string) => {
    await fetch('/auth/switch-tenant', {
      method: 'POST',
      credentials: 'include',
      headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken },
      body: JSON.stringify({ targetTenantId: tenantId }),
    })
  }
  return (
    <ul>
      {memberships.map((m) => (
        <li key={m.tenantId}>
          <button disabled={m.tenantId === subject.tenantId} onClick={() => switchTo(m.tenantId)}>
            {m.tenantId} ({m.role})
          </button>
        </li>
      ))}
    </ul>
  )
}
```

`POST /auth/switch-tenant` validates membership server-side, re-issues the session cookie with the new active tenant, and — over the live WebSocket — presents that cookie on the connection, so calls made afterwards resolve against the new tenant without a reconnect. No re-auth.

Calls, not subscriptions: a subscription already open on that connection was authorized under the previous cookie and keeps running until the client re-subscribes. Re-mount the subscribing components (or reload) if the switch has to change what they show.

## Anti-patterns

- **Reading `document.cookie` for the session.** It's `HttpOnly` — you can't. Use `useSubject` / `useOptionalSubject`.
- **Storing the subject in `localStorage` for "fast access".** It goes stale, leaks via XSS, and there's no security model that justifies it. The cookie IS the cache.
- **Calling auth hooks in non-React contexts.** They depend on React context — use them inside components or custom hooks.



---

<!-- source: en/authentication/cookies.md -->
## Cookie security

_The production cookie checklist — Secure, HttpOnly, SameSite, Domain, Path, Max-Age, CSRF defence._

A session cookie carries the keys to the user's account. Get the attributes wrong and you're handing them out to attackers. This page is the audit list.

## The attributes that matter

| Attribute | Value | Why |
|---|---|---|
| `Secure` | `true` in production | Cookie only sent over HTTPS. Without it, an attacker on the network reads the cookie out of an HTTP request. |
| `HttpOnly` | always | JS can't read it. Defeats XSS-extraction of session tokens. |
| `SameSite` | `lax` (hardcoded) | CSRF defence. Not configurable; the plugin never emits `strict` or `none`. |
| `Domain` | unset (default) OR `.your-product.com` | Unset = host-only (strict). Set = shared across subdomains. |
| `Path` | `/` | Always covers the whole app. Narrower paths cause subtle "cookie missing on some requests" bugs. |
| `Max-Age` | configurable, default 7 days | Session lifetime. |

## Production AuthConfig

```ts
const AUTH_CONFIG: AuthConfig = {
  secret:          process.env.AUTH_SECRET!,         // 32+ bytes from a CSPRNG
  defaultTenantId: 'public',
  cookieSecure:    true,                             // ALWAYS true in prod
  cookieDomain:    '.your-product.com',              // optional; for subdomain sharing
  successRedirect: 'https://app.your-product.com/',
}
```

`AuthConfig` is exactly `{ secret, defaultTenantId, cookieDomain?, cookieSecure?, successRedirect? }`. **`SameSite` is not configurable** — the session builder hardcodes `SameSite=lax`. There is no `cookieSameSite` field.

## SameSite is always lax

The cookie ships `SameSite=lax`. That's the right choice for the typical sign-in flow: `lax` cookies are sent on top-level GETs and cross-origin form POSTs, so the cookie lands before the post-sign-in redirect. `strict` would block cross-origin sign-in entirely, and `none` (cross-site cookies) is a footgun the framework simply doesn't emit. If you want the stricter same-origin posture, put api + web behind one reverse proxy (below) — same-origin requests carry the cookie regardless.

## Same-origin reverse proxy (recommended)

The cleanest production layout:

```
https://acme.com/         → web (landing + dashboard)
https://acme.com/api/*    → api app
https://acme.com/docs/*   → docs site
```

Caddyfile:

```caddyfile
acme.com {
  reverse_proxy /api/*  api:4000
  reverse_proxy /docs/* docs:5181
  reverse_proxy *       web:5191
}
```

With this:

- One origin to the browser = `SameSite=strict` works
- No `Domain` attribute needed (host-only is fine)
- No CORS preflight noise
- One TLS certificate covers everything

This is the layout we recommend for any deploy that isn't multi-region.

## Cross-origin (split api / web)

For multi-region + edge-cached web with the api in one region:

```
https://acme.com         → web (CDN-cached)
https://api.acme.com     → api (one region)
```

You need:

- `cookieDomain: '.acme.com'` so the cookie is set by api.acme.com but flows on acme.com requests
- `SameSite=lax` (already the hardcoded default) so form POST sign-in works
- CORS allow-list on the api: `Access-Control-Allow-Origin: https://acme.com` + `Access-Control-Allow-Credentials: true`

The cookie boundary is the registrable domain (`acme.com`), so `Domain=.acme.com` covers both subdomains.

## CSRF defence

`SameSite=lax` blocks GET-based CSRF + most cross-origin write attempts, and it's the first line of defence. On top of it, the plugin ships a signed double-submit CSRF token: `GET /auth/csrf` (handler `handleCsrf`) sets a readable `voltro:csrf` cookie + returns the token in JSON; the SPA echoes it in the `x-csrf-token` header on every state-changing call, and `authRoutesPlugin()` rejects authenticated mutations whose header and cookie don't match a server-signed token (`verifyCsrf`). The token is HMAC-signed so a cookie-injecting network attacker can't forge one.

## Cookie secret

```bash
# Generate a strong secret
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
```

Store it:

- **Production:** in your secrets manager (AWS SM, Doppler, 1Password, Vercel env). NEVER commit to git.
- **Staging:** different secret than production. Rotating one shouldn't kill the other.
- **CI:** mocked or randomly-generated per-job for tests.

## Rotation

Multi-key rotation ships. Set the new secret as `VOLTRO_SESSION_SECRET` and move the old one to `VOLTRO_SESSION_SECRET_PREVIOUS`; `resolveSessionSecrets()` returns both, `signSession` stamps the current key's `kid`, and `verifySessionKeyed` accepts either during the rotation window. Decommission the old key (drop `_PREVIOUS`) after the max session lifetime — nobody is signed out.

## Audit checklist before going to prod

- [ ] `cookieSecure: true`
- [ ] `AUTH_SECRET` is 32+ bytes from a real CSPRNG, in a secrets manager
- [ ] HTTPS terminates at the edge with HSTS preload enabled
- [ ] Cookies are HttpOnly (verify in browser devtools → Application → Cookies)
- [ ] No JavaScript reads `document.cookie` for the session
- [ ] CORS allow-list is exact origins (no `*` with credentials)
- [ ] Sign-out POST endpoint exists and clears the cookie
- [ ] Different `AUTH_SECRET` per environment
- [ ] Session `Max-Age` matches your risk profile (not 365 days for a banking app)

## What the framework does NOT defend against

- **Server-side bugs that leak the cookie value** (e.g. logging `req.headers.cookie` to an aggregator). Audit your logging pipeline.
- **Compromised user device.** A keylogger on a user's machine sees the password. TOTP/MFA enrolment ships today (see [HTTP handlers](/docs/authentication/handlers#mfa--totp-handlers-shipped)); passkeys ship too (phishing-resistant, no shared secret to keylog — see the [auth plugin overview](/docs/plugins/auth#passkeys--webauthn)). Ultimately devices have to be trusted.
- **Compromised provider.** If your secrets manager leaks `AUTH_SECRET`, every session is forgeable. Rotate immediately + invalidate every session.
- **Phishing.** A user signing in on an attacker-controlled site → attacker has the cookie. Defence is browser-level (SSL pinning, password managers refusing to autofill on wrong domain). Passkeys help here directly: WebAuthn binds the credential to the origin, so an assertion produced on a phishing domain fails the origin/rpId check server-side.

For high-stakes apps (banking, healthcare), layer on:

- Step-up auth for sensitive operations (re-prompt for a TOTP code or password before billing changes)
- Device fingerprinting + new-device alerts
- IP-based anomaly detection
- Login notifications via email



---

<!-- source: en/authentication/authorization.md -->
## Authorization (ReBAC)

_Relationship-based access control — declare per-resource policies (which relations grant which actions), decide with can()/assertCan() (fail-closed, typed AccessDenied), hide forbidden rows from reads, and reactively drop rows from open subscriptions the moment a grant is revoked._

Authorization is **relationship-based (ReBAC)**: access follows from
*relationships* between a subject and a resource (`owner`, `editor`, `viewer`, a
team membership) rather than a flat role. You declare a per-resource policy, the
engine decides with `can(...)`, and — because the framework is reactive — a
**revoked grant removes the now-forbidden rows from every open subscription
live**, with no refresh.

## Relationship tuples

A tuple says *subject `S` has relation `R` on `<type>:<id>`*. They're plain rows
(the framework's `_voltro_rebac_tuples` table, or your own relation rows):

```ts
// alice is the owner of todo:42 ; the acme team can view it
{ subjectId: 'alice', relation: 'owner',  resourceType: 'todo', resourceId: '42' }
{ subjectId: 'acme',  relation: 'viewer', resourceType: 'todo', resourceId: '42' }
```

## Declare a policy

`defineResourcePolicy` maps each action to the relations that grant it (ANY-of),
with optional relation `implies` (a transitive closure — `owner ⇒ editor ⇒
viewer`). Registered at import; the capability map exposes the graph.

```ts
import { defineResourcePolicy } from '@voltro/runtime'

export const todoPolicy = defineResourcePolicy({
  resourceType: 'todo',
  actions: {
    view:   ['viewer', 'editor', 'owner'],
    edit:   ['editor', 'owner'],
    delete: ['owner'],
  },
  implies: { owner: ['editor'], editor: ['viewer'] },
})
```

## Enforce it declaratively — `guards:`

`can` / `assertCan` above are the imperative form: you load the tuples and make
the decision inside the handler. That works, and it is what you reach for when
the check needs data you have already loaded.

For the ordinary case — "may this caller perform ACTION on the row this input
names?" — declare it on the descriptor instead:

```ts
export const todoUpdate = defineMutation({
  name: 'todos.update',
  input: Schema.Struct({ id: Schema.String, title: Schema.String }),
  output: Schema.Void,
  guards: [{ action: 'edit', resourceType: 'todo', resource: (input) => input.id }],
})
```

The framework resolves it BEFORE the executor runs — for a mutation, before the
transaction opens — and fails with a typed `ScopeError` naming
`<resourceType>:<action>`.

Why the declarative form is not just shorter: an in-handler check is one an
author can forget, and a forgotten check is a silent hole rather than an error.
The same is true of the older pattern of hand-maintaining a map from rpc tag to
policy rule and installing it as an interceptor — that map is **fail-open by
omission**: add an endpoint, forget the entry, and nothing anywhere tells you.
A guard on the descriptor cannot be forgotten for an rpc that exists, because
it is part of the rpc.

Scope guards and relationship guards live in the same array and ALL must pass:

```ts
guards: [
  { scope: 'todos:write' },                                        // may you edit todos at all
  { action: 'edit', resourceType: 'todo', resource: (i) => i.id }, // may you edit THIS one
]
```

A guard answers **may you call this**. It cannot answer **which rows may you
see** — a list has no single resource to name. For visibility that follows from a
relationship ("tickets on teams I hold a role on"), declare a
[row filter](/docs/authentication/row-level-security) instead; it AND-merges a
subject-derived predicate into every read, so it narrows and never grants.

### Activate it: register a tuple source

A relationship guard needs to read the caller's relations. That comes from the
registered **tuple source**:

```ts
// app.config.ts or a *.startup.ts
import { setTupleSource, loadResourceTuples } from '@voltro/runtime'

setTupleSource((req) =>
  loadResourceTuples(store, req.subjectId, req.resourceType, req.resourceId))
```

`voltro dev` / `voltro serve` register this default for you, reading
`_voltro_rebac_tuples` — **but only if you registered nothing**. Register your
own when your relationships already live in your own tables: a `teamMembers` row
is a relation; you should not have to copy it into a framework table to authorize
against it. Yours wins, wherever you register it (`app.config.ts` or a
`*.startup.tsx`), and the boot says so:

```
policy guards: using the app-registered tuple source
```

That line is worth knowing, because its absence is the diagnosis when your own
source is not being consulted.

**Every unanswerable case denies.** No tuple source registered, no policy for
that `resourceType`, an input that doesn't identify a resource, a tuple source
that throws — each is a denial, not a pass. An authorization question nobody can
answer is a refusal; treating it as a pass is how a policy layer ends up
enforcing nothing while looking like it does.

**A guard naming an unregistered `resourceType` refuses the BOOT.** Denying is
correct per call and useless as a deployment outcome: the app comes up green and
every guarded procedure is down, with a log line per refused call as the only
sign. A deployment put a number on it — 39 procedures, team settings through role
administration. So the two facts are compared once, after the startups have run,
and a missing registration names the type and the procedures that demanded it.
The commonest cause is a typo: the `resourceType` in a guard and the one in
`defineResourcePolicy` are two strings, and nothing but that check compares them.

### The source sees the whole subject

`req.subject` is the caller, not just `req.subjectId`. That matters whenever a
CREDENTIAL is narrower than the person holding it — an API key above all:

```ts
setTupleSource(async (req) => {
  // A key minted for one team must not act on another. `req.subjectId` is the
  // OWNING USER, so a source reading only memberships passes an owner who
  // belongs to both teams.
  const boundTeam = req.subject.metadata?.teamId
  if (boundTeam !== undefined && boundTeam !== req.resourceId) return []
  return loadResourceTuples(store, req.subjectId, req.resourceType, req.resourceId)
})
```

Narrow it yourself: `Subject` is a union whose system and anonymous members carry
no `metadata`. Without this a declared guard could not express the binding, so an
app had to keep a hand-written check in the executor beside it — and a guard that
must always run paired with a hand-written check is not a declaration.

### Guards are re-checked on every subscription delivery

A subscription is a long-lived grant. Its guards — scope and relationship alike
— are re-evaluated before each delivery, so revoking a relation mid-session ends
the stream with the typed error instead of continuing to push rows.

## Every procedure decides — `guards:` or `openAccess:`

A procedure that declares neither is **refused at boot**. `guards:` used to
default to "allowed", so a discovered `*.query.ts` / `*.mutation.ts` /
`*.action.ts` / `*.stream.ts` with no guard was callable by **any authenticated
session** — the door defaulted open, and nothing said so.

The same rule covers **events**: a `*.event.ts` declaration is a wire surface
too, and one that declares neither `guards:` nor `openAccess:` was silently
subscribable by anyone who could open the socket. `defineEvent` takes the same
two answers — see [Events](/docs/data/events).

There are exactly two answers, and they are not the same claim:

```ts
export const invoiceList = defineQuery({
  name: 'invoices.list',
  guards: [{ scope: 'invoices:read' }],      // the caller must hold a scope
  …
})

export const pricing = defineQuery({
  name: 'pricing.current',
  openAccess: 'public pricing page — reads no caller data',   // anyone may call it, and why
  …
})
```

`openAccess` takes a **reason, not a boolean**. That is the point of it: the
reason is what a reviewer reads later, and it is what makes *"we decided this is
open"* distinguishable from *"nobody looked"*. Without such a marker, the only
way to satisfy a default-deny gate is to add a guard — so every genuinely open
endpoint grows a scope every caller already holds. That rubber stamp reads as
protection and enforces nothing, which is a worse state than the hole it
replaces.

A procedure that only other **server** code calls wants neither: mark it
`internal: true` and it leaves the wire entirely (no client-group entry, no
route). `openAccess` on an internal procedure is refused — there is no wire
surface to make a decision about.

### The gate

`voltro dev` and `voltro serve` run the same check at boot, and `voltro doctor`
runs it as a preflight (non-zero exit; `accessDecisions` in `--json`). The
refusal names **every** offending procedure with its file, because the fix is one
pass over the whole list:

```
[access] 3 wire-exposed procedures or events declare no access decision, and
this app runs with `security.defaultDeny`:

    invoices.list  (query)
      src/api/invoices.query.ts
    …
```

`voltro doctor` is the fastest way to get the list without a failed boot.

### Turning it off

One field, in `app.config.ts`, for the whole app:

```ts
export default defineApiConfig({
  security: { defaultDeny: false },
})
```

There is deliberately **no environment variable** for this. The only direction
anyone reaches for is off, and an env var is how a security default becomes
permanently off in one CI job with no diff to review. `voltro doctor` keeps
listing the undecided procedures while it is off, marked advisory.

### What it does NOT cover — and what covers the rest

The **boot** gate reads **your app's own** discovered procedures and events. The
procedures a plugin declares are the plugin author's decision and are not judged
at boot — adopting this does not turn into a bug report against a plugin you
installed.

They are not unpoliced, though: the same `security.defaultDeny` is also enforced
**per request in the dispatch spine**, as defense in depth. A descriptor that
reaches the wire with no access decision — a plugin route, a hand-bound
descriptor — is refused with the same typed `ScopeError` before the transaction
opens or any external I/O runs. Every first-party plugin route declares its own
decision (a scope where a real authority exists — e.g. `billing:manage`,
`storage:browse` — or `openAccess` with the reason on the routes that are
self-scoped or anonymous-capable by design; each plugin's page lists them). A
third-party plugin that declares neither on a route will see that route refused
per-request under default-deny — the fix is one field on the route, exactly as
for your own procedures.

## Decide — `can` / `assertCan`

`can(subject, action, resource, { policy, tuples })` is the decision; `assertCan`
throws the typed `AccessDenied`. It **fails closed**: `admin:full` scope is the
only bypass, an anonymous subject is denied, a cross-tenant resource is denied,
an unknown action is denied — otherwise allow iff the subject's effective
relations intersect the action's grant set.

```ts
import { assertCan, loadResourceTuples, AccessDenied } from '@voltro/runtime'
import { todoPolicy } from '../policies/todo.policy'

// in a mutation's *.server.ts
const tuples = await loadResourceTuples(ctx.store, ctx.request.subject.id, 'todo', input.id)
assertCan(
  ctx.request.subject,
  'edit',
  { type: 'todo', id: input.id, tenantId: ctx.request.subject.tenantId },
  { policy: todoPolicy, tuples },
) // throws AccessDenied on a deny
```

Declare `error: AccessDenied` on the descriptor so the denial surfaces to the
client **typed + pattern-matchable**, never a bare 500. For per-rpc enforcement
without a hand-written guard, `buildRebacInterceptor` runs the same `can()` check
in the rpc pipeline; `buildRebacReadFilter` / `visibleRows` hide forbidden rows
from a read instead of failing it.

## Live revocation

Because reads are reactive, authorization is too. `revokedIds(before, after)`
is the core: the ids a subject could see before a permission-changing write but
not after. The reactive layer pushes that delta to every open subscription, so
the moment alice's grant on `todo:42` is revoked, the row **disappears from her
screen** — no refetch, no refresh. (Enforcement is phased first; revocation is
the headline that builds on it.)

## Client

`useResourceCan` / `useResourceCans` resolve a subject's permission reactively
(fail-closed), so the UI hides an action the moment it's revoked:

```tsx
import { useResourceCan } from '@voltro/client'

const canEdit = useResourceCan('app', 'todos.can', { action: 'edit', resourceType: 'todo', resourceId: id })
// canEdit.allowed: boolean (false until the first verdict); canEdit.pending: boolean.
```

Full API — including the batch `useResourceCans` for per-row gating — in
[usePermissions](/docs/ui/client-utilities/use-permissions).

## Capability map

`rebacPolicyGraph()` returns every resource type, its actions, the relations each
grants, and the implication edges — the policy graph a dashboard or an AI agent
reads to reason about authority without grepping the code.



---

<!-- source: en/authentication/row-level-security.md -->
## Row-level security

_setRowFilter — a subject-derived predicate AND-merged into every read, so relational visibility ("rows on teams I hold a role on") is declared once instead of hand-written into every list handler and every subscription._

Reads already scope themselves by tenant and by soft-delete, and a descriptor's
[`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards)
decide whether you may call a procedure **at all**. Neither of those says which
**rows** you may see.

That gap matters as soon as visibility is *relational* — "tickets on teams I hold
a role on". Without a row filter, that predicate has to be hand-written into
every list handler and every subscription, and a filter you have to remember is a
filter you only have to forget once.

`setRowFilter` declares it once. The framework AND-merges the resulting predicate
into every read.

## The two phases

```ts no-check
setRowFilter({
  load:      (subject) => Effect<Ctx>,                    // ASYNC, once per request
  predicate: (ctx, table) => Predicate | undefined,       // PURE + SYNC, per read
})
```

- **`load`** resolves everything the predicates need — the memberships, the role
  rows, the project ids — **once per request**. It may read the store. This is
  the expensive half.
- **`predicate`** derives the filter for one table from what `load` already
  fetched. It runs on **every read**, so it must be pure and synchronous. Return
  `undefined` for a table this filter does not constrain — which is most tables.

### Why two halves rather than one function

A single async `(subject, table) => Promise<Predicate>` would be simpler to
declare and much worse to run. Every read on the hot path would await, and the
obvious implementation would re-query the membership tables **once per query** —
so a handler that reads five tables pays five membership lookups.

Splitting the phases makes the per-read cost a map lookup and makes the
per-request cost explicit and visible: one load, reused.

## A worked example

The motivating shape — rows on teams the caller holds a role on:

```ts no-check
// apps/api/rls.startup.ts
import { Effect } from 'effect'
import { eq, inSet } from '@voltro/database'
import { setRowFilter } from '@voltro/runtime'
import { database } from './database/index'

setRowFilter({
  // ASYNC — once per request. Read your own tables here.
  load: (subject) =>
    Effect.promise(async () => {
      const rows = await database.teamMembers
        .where(eq('userId', subject.id ?? ''))
        .all()
      return rows.map((row) => row.teamId)
    }),

  // PURE + SYNC — runs on every read.
  predicate: (teamIds: ReadonlyArray<string>, table: string) =>
    table === 'tickets' ? inSet('teamId', teamIds) : undefined,
})
```

Register it at boot — `app.config.ts` or a `*.startup.ts`. It is process-global
and last-write-wins. `Ctx` is whatever your `load` returns; the framework never
inspects it.

With that registered, an ordinary list query needs no filter of its own:

```ts no-check
// apps/api/queries/tickets.list.query.server.ts
export default () => database.tickets.orderBy('createdAt', 'desc')
```

A caller with no memberships gets zero rows. Nothing in the handler says so.

## It can only narrow, never grant

The predicate is **AND-merged** onto whatever the handler already asked for — it
never replaces it. A row filter cannot widen a query, so it can never become an
accidental grant:

```ts no-check
// the handler asks for one ticket; the filter still applies
database.tickets.where(eq('id', 'ticket-42'))
// → id = 'ticket-42' AND teamId IN (…the caller's teams)
```

## Both read paths are filtered

The filter applies to descriptor reads **and** to the fluent builder. A filter
present on only one read path is not a filter, it is a detour:

```ts no-check
await ctx.store.query(tickets.descriptor)   // filtered
await ctx.store.select('tickets').all()     // filtered
```

## When `load` fails

`load` reads your store — for relational visibility it *must* — which makes it
exactly the kind of call that blips. Two separate questions follow from a
failure, and the answers are deliberately different.

### First: is the failure even real? (`retry`)

A transient failure must never reach the decision below, because once it gets
there it is indistinguishable from an authorization answer. So `load` runs under
a bounded retry before anything is concluded from it:

```ts no-check
import { Schedule } from 'effect'

setRowFilter({
  load,
  predicate,
  retry: Schedule.recurs(5),   // your own schedule
  // retry: false,             // exactly one attempt
})
```

The default is `DEFAULT_ROW_FILTER_RETRY` (exported from `@voltro/runtime`):
**three attempts, backing off exponentially from 20ms** — about 60ms of added
latency in the worst case. It is sized for a blip (a connection reaped from the
pool, a failover flap), not for an outage. A `load` still failing after that is
not having a bad moment, and stretching the schedule only turns a fast honest
error into a slow one while holding the request open.

### Then: what does a real failure mean? (`onLoadError`)

Not "you may see nothing". **We cannot tell what you may see.** Those are
different facts and only one of them is a fact — so the default raises a typed
error:

```ts no-check
setRowFilter({
  load,
  predicate,
  onLoadError: 'fail',   // default — raises RowFilterUnavailable
  // onLoadError: 'deny', // degrade to zero rows instead
})
```

- **`'fail'` (default)** — the request fails with the typed
  `RowFilterUnavailable`. Handle it in your UI as an error state, the same as any
  other failed request.
- **`'deny'`** — refusal is expressed as a predicate matching nothing, so the
  read returns an empty result. Choose this only if you have looked at the screen
  and are content for it to render empty during an outage. Your `onError`
  reporter still fires, so the failure stays findable in logs even though the
  response is a 200.

The default changed *to* `'fail'`, and the reasoning is worth stating plainly
because the old default looked defensible: an empty result for an infrastructure
failure is byte-identical to legitimate emptiness. The user reads "you have no
tickets". The operator reads a healthy 200. The outage is invisible to both —
the most misleading outcome on offer. Every constrained page **is** broken when
this happens, and saying so is the only outcome either party can act on.

### There is no fail-open option

A frequent request, and a deliberate refusal: there is no policy that serves
**unfiltered** rows when the filter is unavailable, falling back to whatever
check the handler carries.

Failing open on an authorization filter leaks data precisely when the system is
under stress and nobody is reading dashboards. And it is only safe if every
handler still carries its own row-level check — which is the entire thing a row
filter exists to remove. A codebase where fail-open is safe is a codebase that
did not need `setRowFilter`.

Both policies above are fail-**closed**: neither can ever produce an unfiltered
read.

### Subscriptions

A resolution failure mid-stream **revokes** the subscription and emits a typed
error frame, rather than delivering an empty snapshot — an empty snapshot on a
live subscription reads to a client as "every row you could see was just
deleted". Make sure your subscription error handling surfaces it.

## A path that cannot apply the filter refuses, rather than serving rows

If a filter is registered and a scoped store is built without a resolved scope,
the store **throws**. It does not fall back to unfiltered reads.

That fallback used to exist, and it is the reason this section does. A team
measured four read paths returning every row of the tenant to every employee,
on both transports, with `row filter registered` in the boot log and a green
test suite. The registration lived in a module-local variable, so an app's
`*.startup.tsx` and the framework's request pipeline could hold two different
copies of it — the serve bundle inlines the framework while app modules stay
external, and a strict pnpm tree can resolve one version into two directories.
The pipeline read "no filter registered", which was indistinguishable from an
app that has none, and served everything.

The registration is process-global for real now (`globalThis`, so every copy
shares one cell), and the ambiguity that made the failure silent is gone: those
two readings are different claims and only one of them is a decision.

If a code path is deliberately unfiltered — a system sweep, a migration, a
seeding helper in a test — say so:

```ts
wrapStoreWithMixinBehaviour(store, { subject, schemaRegistry, rowFilter: NO_ROW_FILTER })
```

`runAsSystem`, change-stream subscribers and the webhook trigger context already
do this; a system subject bypasses row filters by design, and it is now written
down rather than inferred from an absence.

## What does *not* bypass it

| | Bypasses the row filter? |
|---|---|
| `.unscoped()` / `crossTenant` | **No** |
| a `system` subject | Yes |

`.unscoped()` and `crossTenant` exist for legitimate cross-tenant admin reads.
They opt out of **tenant isolation**, not out of **authorization** — letting them
also drop row visibility would turn an isolation opt-out into an authorization
one, which is exactly the silent widening this feature exists to prevent.

Only a `system` subject bypasses, because a system subject is the framework
acting as itself — janitor sweeps, migrations, the scheduler — rather than on
behalf of a user. That bypass is deliberately the narrow, explicit one.

Apps that register no filter pay nothing.

## Subscriptions re-resolve it

A subscription is the one read path that stays open for hours, so it is the one
where a stale filter would matter most. Before every delivery the runtime
re-derives the read from the **unfiltered base descriptor** and re-applies the
freshly resolved filter.

A membership that ends mid-subscription therefore stops serving rows — the
caller's open ticket list drops the rows they can no longer see, without a
refresh and without the subscription having to be torn down.

## Row filters vs. guards

They answer different questions, and a complete policy usually wants both:

| | Question | Failure |
|---|---|---|
| [`guards:`](/docs/authentication/authorization#enforce-it-declaratively-guards) | May you call this procedure? | typed `ScopeError`, before the executor runs |
| `setRowFilter` | Which rows may you see? | the rows are simply absent — unless the filter itself could not load, which is a typed `RowFilterUnavailable` |

A guard is the right tool for "may this caller edit *this* ticket". A row filter
is the right tool for "which tickets appear in the list at all" — a question a
guard cannot answer, because there is no single resource to name.
