# Auth

> Password + session-cookie auth — overview. Full docs in the Authentication section.



---

<!-- source: en/plugins/auth.md -->
## Auth

_Password + session-cookie auth — overview. Full docs in the Authentication section._

`@voltro/plugin-auth` ships the full server-side auth suite — password hashing (with rehash-on-verify), HMAC session cookies (multi-key rotation + sliding-window auto-renewal), magic-link + password-reset flows, email verification, tenant invitations, user impersonation, passkeys/WebAuthn (with atomic clone detection), CSRF, session enumeration + revocation, multi-tenant memberships + switch-tenant, TOTP/MFA with sign-in enforcement + recovery codes — plus React glue (`SubjectProvider`, `useSubject`, `RequireAuth`) and a browser passkey ceremony helper. Identity is a pluggable [strategy](/docs/authentication/strategies) protocol — the password flow is the default, and external IdPs stack on top.

The single `authRoutesPlugin()` mounts **every** auth HTTP route under `/auth` — you don't hand-wire endpoints.

**Status:** ✓ shipped.

This page is a 60-second overview. The dedicated [Authentication section](/docs/authentication/overview) has the depth:

- [Passwords](/docs/authentication/passwords) — scrypt hashing, timing-oracle defence
- [Sessions](/docs/authentication/sessions) — HMAC-SHA256 signed sessions
- [The Subject](/docs/authentication/subject) — the typed `ctx.subject` model
- [Auth strategies](/docs/authentication/strategies) — the `AuthStrategy` protocol + chain
- [External identity providers](/docs/authentication/external-idp) — WorkOS, Kinde, Clerk, Auth0, Supabase, OIDC
- [HTTP handlers](/docs/authentication/handlers) — `handleSignIn`, `handleSignUp`, `handleSignOut`
- [User stores](/docs/authentication/user-stores) — `memoryUserStore`, `postgresUserStore`, custom
- [React on the web side](/docs/authentication/react) — `SubjectProvider` + hooks
- [Cookie security](/docs/authentication/cookies) — production checklist

## Quick wire-up

Add `authRoutesPlugin()` to your api's `plugins` array. It mounts the entire auth surface under `/auth` on the same listener the rpc server uses — no hand-wired endpoints. The documented email path forwards magic-link + password-reset mail to `@voltro/plugin-mail` via `mailSender(...)`:

```ts
// app.config.ts
import { authRoutesPlugin, postgresUserStore, mailSender } from '@voltro/plugin-auth'
import { mailPlugin, MailService } from '@voltro/plugin-mail'
import { bindConnectionCredential } from '@voltro/runtime'
import { Effect } from 'effect'

// `mail` is the yielded MailService from the mail plugin's services layer.
const auth = (mail: MailService) =>
  authRoutesPlugin({
    store:           postgresUserStore(sql),     // sql: a provided @effect/sql SqlClient
    secret:          process.env.VOLTRO_SESSION_SECRET!,
    defaultTenantId: 'public',
    cookieSecure:    process.env.NODE_ENV === 'production',
    appBaseUrl:      'https://app.example.com',
    sendEmail:       mailSender(mail),           // ← plugin-mail synergy (the default wiring)
    rebind:          bindConnectionCredential,    // ← switch-tenant presents the new cookie on the live connection
    passkey: {
      rpId:   'example.com',
      rpName: 'Acme',
      origin: 'https://app.example.com',
    },
  })

export default {
  type: 'api' as const,
  name: 'myApi',
  plugins: [ mailPlugin({ provider: 'resend', from: 'Acme <hi@acme.com>' }) /*, auth(...) */ ],
}
```

`sendEmail` is a plain injected hook (`{ to, subject, html, text, kind, actionUrl } => Promise<void>`); `mailSender(mail)` is the first-class adapter that forwards it to `MailService.send`. Omit `sendEmail` and the magic-link / reset routes still mint + persist the token but can't deliver it (they return `202` so account existence never leaks).

```ts
// app.config.ts — add identity strategies (the built-in password cookie always runs first)
import { jwtBearerStrategy } from '@voltro/protocol/jwt'
import { apiKeyStrategy } from '@voltro/protocol/apikey'

export default {
  type: 'api' as const,
  name: 'myApi',
  auth: { strategies: [ jwtBearerStrategy({ /* … */ }), apiKeyStrategy({ /* … */ }) ] },
}
```

The plugin:

- Mounts every auth route under `/auth` via `authRoutesPlugin()` (also re-exports the underlying Effect-typed handlers — `handleSignIn`, `handleSignUp`, `handleMagicLinkRequest`, `handleSwitchTenant`, … — if you'd rather mount a subset yourself).
- Resolves `ctx.subject` via `AuthMiddleware` running the strategy chain — the built-in `voltroPasswordStrategy` reads the session cookie.
- Exposes the typed user store via `postgresUserStore(sql)` (synchronous; the caller owns the `SqlClient`) or `memoryUserStore()` for dev/tests.
- Contributes the auth tables (`usersTable`, `sessionsTable`, `membershipsTable`, `authTokensTable`, `passkeysTable`, `invitationsTable`, `impersonationGrantsTable`, …) via `authTables`.

## Routes mounted under `/auth`

| Route | Auth | Purpose |
|---|---|---|
| `POST /auth/sign-in` · `/sign-up` · `/sign-out` | — | password flow (sign-out also revokes the session row). For an MFA-enrolled user, `sign-in` returns an **mfa-required challenge**, not a session — see below |
| `POST /auth/mfa/verify` | — | complete the second factor (TOTP or recovery code) with the pending token → issues the real session |
| `GET  /auth/session` | cookie | session probe: current subject + `renewed` flag; re-issues the cookie when the sliding window (or a key rotation) asks for it |
| `GET  /auth/csrf` | — | issue a double-submit CSRF token |
| `POST /auth/magic-link` · `/magic-link/callback` | — | passwordless sign-in |
| `POST /auth/password-reset` · `/password-reset/confirm` | — | reset flow (confirm revokes all sessions) |
| `GET  /auth/sessions` · `POST /auth/sessions/revoke` · `/sessions/revoke-others` | cookie | device list + revocation |
| `GET  /auth/memberships` · `POST /auth/switch-tenant` | cookie | multi-tenant membership + active-tenant switch |
| `POST /auth/verify-email` · `/verify-email/callback` | — | request/resend a confirmation link (uniform `202`); redeem one (issues **no** session) |
| `POST /auth/invitations` · `GET /auth/invitations` · `POST /auth/invitations/revoke` | cookie | create / list / withdraw tenant invitations (needs `invitations` config) |
| `POST /auth/invitations/preview` · `/invitations/accept` · `/invitations/sign-up` | — / cookie | inspect a token, redeem it as the signed-in user, or redeem it by creating the invited account |
| `POST /auth/impersonate/start` · `/impersonate/stop` | cookie | act as another user and end it (needs `impersonation` config) |
| `POST /auth/mfa/enroll/start` · `/enroll/verify` · `/unenroll` · `/recovery-codes/regenerate` | cookie | TOTP enrolment (needs `mfa: { issuer }` config); `enroll/verify` returns one-time recovery codes |
| `POST /auth/passkey/register/options` · `/register/verify` | cookie | passkey enrolment |
| `POST /auth/passkey/assert/options` · `/assert/verify` | — | passkey sign-in |

State-changing authenticated routes require a valid `x-csrf-token` header matching the `voltro:csrf` cookie.

While a session is **impersonated**, the account-security routes are refused with `403 forbidden_while_impersonating` — see [User impersonation](#user-impersonation-log-in-as).

## Schema

```ts
import { authTables } from '@voltro/plugin-auth/schema'
// authTables = [usersTable, sessionsTable, membershipsTable, authTokensTable,
//               passkeysTable, recoveryCodesTable, passkeyChallengesTable,
//               loginAttemptsTable, invitationsTable, impersonationGrantsTable]
```

Spread `authTables` into your `database/index.ts` handle + `voltro migrate` creates the tables.

## Multi-tenant memberships + switch-tenant

A user belongs to MANY tenants. The Subject carries an active `tenantId` plus its memberships (in `metadata.memberships`, read via `subjectMemberships(subject)`). `POST /auth/switch-tenant` validates the user is a member of the target tenant, re-issues the session cookie with the new active tenant, and — when invoked over the live WebSocket with a `clientId` — **presents that same cookie on the connection** via `bindConnectionCredential`.

It presents a CREDENTIAL, not a Subject, and that is the whole design. The rebound connection is resolved by the full auth chain on its next call — session revocation, `auth.resolveScopes`, the scope cache, the credential-expiry bound — exactly as a reconnecting browser would be. It used to hand the middleware a resolved Subject, which the middleware returned verbatim: the connection then kept that authority for its whole life, immune to a revocation performed anywhere else. (And it only worked under `voltro dev`; `voltro serve` had no such path at all, so the rebind silently did nothing in production.)

What a rebind does NOT do: re-scope subscriptions already open on that connection. They were authorized under the previous cookie and run until the client re-subscribes. Calls made after the rebind see the new tenant.

## Email verification

`users` carries a nullable `emailVerifiedAt`, and `POST /auth/verify-email` + `/verify-email/callback` mint, send and redeem a confirmation link over the same single-use hashed-token table the magic-link and reset flows use.

**What an unverified account may do is a product decision, so it is a config field with three values** rather than a behaviour the framework picks for every app:

```ts
authRoutesPlugin({
  // …store, secret, defaultTenantId, sendEmail…
  emailVerification: {
    policy: 'strict',                                    // 'off' (default) · 'soft' · 'strict'
    exemptAccountsCreatedBefore: new Date('2026-08-11'),  // your deploy instant
  },
})
```

| policy | login | the session |
|---|---|---|
| `'off'` (default) | proceeds | carries **no** mark |
| `'soft'` | proceeds | marked — `isEmailVerified(subject)` is `false` |
| `'strict'` | refused, `403 email_not_verified` | never issued |

**Why `'off'` is the default.** The column arrives `NULL` on every existing row. Under `'strict'` as a default, the first boot after upgrading would refuse the next login of every user you already have — a total authentication outage caused by missing data rather than by anything a user did. `exemptAccountsCreatedBefore` is the adoption seam: accounts created before that instant count as verified, so you can turn `'strict'` on without a backfill.

**`isEmailVerified(subject)` is tri-state, and `null` is not `false`.** A session minted while the policy was `'off'` says nothing about verification. Reading "no mark" as "unverified" would refuse every live session at the moment you switch the policy on — the same outage one layer up. Gate on `=== false`.

**Three links prove an address, not one.** A magic link and a completed password reset are inbox round-trips exactly as a verification link is, so both stamp `emailVerifiedAt` (keeping the first timestamp — the column answers "since when", not "last clicked"). Without that, `'strict'` deadlocks a magic-link-only user: they can prove their address by signing in, and are refused the sign-in for not having proved it.

**A verification link is not a credential.** Redeeming one marks the address and issues **no** session. Treating it as a sign-in would turn a link that sits 24 hours in a mailbox — and in every relay along the way — into a day-long credential.

`'strict'` is enforced through the [subject-guard seam](#post-authentication-subject-guards), so it covers password, MFA verify, magic-link and passkey sign-in from one wiring. `authRoutesPlugin` installs the guard when you set the policy; if you mount the handlers yourself, add `emailVerificationGuard(config)` to `subjectGuards` — setting the policy alone changes only what the session is *marked* with.

The request endpoint answers a uniform `202` (unknown address, already-verified address and cooled-down resend are indistinguishable) and sends at most one mail per `resendCooldownSeconds` (default 60). Without that bound, "resend" is a mail-bomb primitive aimed at any address known to have an account.

## Tenant invitations

`invitations` joins `authTables`, and six routes mount when you pass an `invitations` config:

```ts
authRoutesPlugin({
  // …store, secret, defaultTenantId, sendEmail, appBaseUrl…
  invitations: {
    ttlSeconds: 7 * 24 * 60 * 60,          // default
    inviterRoles: ['owner', 'admin'],      // default
    roleRank: ['owner', 'admin', 'member', 'viewer'],  // default, most privileged first
    maxPending: 500,                       // default
  },
})
```

**An invitation is a credential that grants access to someone else's data**, so it gets the token discipline of one — 32 CSPRNG bytes, only the SHA-256 stored, single-use via one conditional `UPDATE … RETURNING`, an expiry — plus three properties a login token has no need for:

- **It is addressed.** The invited address is compared against the accepting user's; a mismatch is `403 invitation_email_mismatch`. A link forwarded, leaked into a channel or intercepted is refused rather than silently granting whoever opens it first.
- **It carries its own authority, chosen by the inviter.** The accept request is `{ token }` and nothing else — there is no field an invitee could use to name their own role.
- **It is revocable**, tenant-scoped in the SQL predicate, so an admin of one tenant cannot withdraw another's by id.

**An inviter can never grant a role above their own.** That direction is not configurable: an `admin` who can mint an `owner` invitation and accept it from a second address has promoted themselves, which makes every role boundary in the product advisory. `canGrantRole` replaces the whole rule for a model that is not a line (a matrix, a per-tenant plan) — keep it a refusal by default. A role the ranking does not know is grantable only by the top role; treating an unfamiliar `superadmin` as probably-harmless is how it gets handed out by an `admin`.

Both "the invitee already has an account" cases are covered. Signed in as the invited address, `POST /auth/invitations/accept` writes the membership. Signed out with no account, `POST /auth/invitations/sign-up` creates one — with the address taken from the **invitation**, never from the request, and marked already verified, since the invitation arrived in that mailbox and came back. An existing address is answered `409 account_exists` and the invitation is not burned.

Re-inviting **supersedes**: every pending invitation for the same (tenant, address) is revoked before the new one is issued, so a resend cannot leave live tokens behind. `maxPending` bounds a tenant so a compromised admin account is not a mail cannon. The admin list never returns `tokenHash` — a hash verifies a guessed plaintext offline, and an admin list is not a place to publish a verifier.

`invitations` is registered with the retention sweep at 90 days (`VOLTRO_INVITATIONS_TTL_HOURS`), armed only when the feature is configured.

## User impersonation ("log in as")

An impersonated session that is indistinguishable from a real one does not merely lack a feature — it retroactively destroys the audit trail of the whole product. Every row an agent touches is attributed to the user, so afterwards nobody can answer *"did the customer delete this, or did we?"* — including for the incident where it matters. Everything below follows from that.

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

authRoutesPlugin({
  // …store, secret, defaultTenantId…
  impersonation: {
    // REQUIRED. A function, never a role — see below.
    authority: ({ actorUser }) => Effect.succeed(supportAgentIds.has(actorUser.id)),
    // REQUIRED. Fires for 'started', 'stopped' AND 'refused'.
    audit: (event) => { void recordSupportEvent(event) },
    maxDurationSeconds: 900,   // default — 15 minutes
    requireReason: true,       // default
  },
})
```

**`authority` is a function, never a role, and has no default.** Two separate reasons. "A role that happens to be admin-ish" is how this becomes a privilege-escalation path in a product where `admin` means "can edit the pricing page". And mechanically: the subject these routes resolve comes from the session cookie, which [carries identity only](/docs/authentication/sessions#the-cookie-carries-identity-never-authority) — it has no `scopes` at all, so a scope-based default would be unsatisfiable by every caller, i.e. a feature that refuses everyone.

**`audit` is required** because impersonation's whole risk is an unrecorded action. A config that let you enable it while leaving the destination unset would make the dangerous half optional and the safe half opt-in.

**The mark lives in two places with different failure modes.** `subject.metadata.impersonation` travels with the cookie and reaches the client, so a banner needs no extra endpoint (`impersonationOf(subject)` reads it). An `impersonationGrants` **row** is written *before* the session exists — so a session can never be reachable without the record naming who is behind it — and no redaction policy can drop it.

**The durable audit trail records the mark on its own, with nothing to wire.** `@voltro/plugin-audit` lifts it out of the subject *before* any redaction runs and stores it as `AuditEvent.impersonation` (its own `impersonation` column). No `redactSubject` setting can take it — not `'metadata'`, not a custom function that erases the subject wholesale.

> This used to require `redactSubject: impersonationAuditRedactor()`, and the docs said so in bold. The default `redactSubject: 'metadata'` replaces the whole metadata bag — right in general, that bag is where a per-user provider credential lands — and it took the mark with it. So on defaults an impersonated action recorded indistinguishably from the user's own, which is the one distinction an audit trail exists to make. An audit property that depends on someone reading a note is not a property. `impersonationAuditRedactor()` still exists and still works; it is no longer load-bearing.

**Time-bounded means the cookie expires**, not that a row says it should. The grant duration is the cookie's `Max-Age`, the session row's `expiresAt` and the grant's `expiresAt`, minted from one number. A caller may request *less*; a request for more is clamped, never obeyed.

**Stopping** closes the grant, deletes the impersonated session row and drops it from the revocation cache — so a copy of that cookie taken during the grant dies immediately, not at its own expiry — then re-issues the impersonator's own, never-revoked session for its **remaining** lifetime. Restoring does not extend their login. An actor whose own session died meanwhile is signed out rather than left as somebody else.

**Four escalation refusals:**

| refusal | what it stops |
|---|---|
| `self_impersonation` | acting as yourself — every action marked impersonated with no second identity behind it, i.e. noise in the field an investigator reads |
| `nested_impersonation` | A→B, then as B→C. The mark carries one actor, so a chain attributes C's session to B — reaching any account with a **forged** attribution |
| `target_may_impersonate` | acting as someone who can themselves impersonate. The probe is `authority` evaluated with the identities **swapped** ("could the target impersonate me?"), so there is no second policy to keep in step |
| `forbidden_while_impersonating` | MFA enrolment/removal, recovery-code regeneration, passkey registration, switch-tenant, revoke-other-sessions, and starting another impersonation. Without this a 15-minute grant converts to permanent access in one request: enrol a passkey as the user and the time bound is decoration |

The impersonator also gains no authority the target lacks, by construction: the cookie **is** the target's identity, carries no scopes, and authority is re-resolved per request from that identity. Being impersonated does not clear the target's brute-force lockout either — a support action must not undo the protection on the account someone is hammering.

`impersonationGrants` is registered with the retention sweep at 365 days (`VOLTRO_IMPERSONATION_GRANTS_TTL_HOURS`) — matching the audit log, because it answers the same class of question — armed only when the feature is configured.

## MFA / TOTP sign-in enforcement

MFA is a real sign-in gate, not just enrolment. **Enrolment** (`POST /auth/mfa/enroll/*`, needs `mfa: { issuer }` in the plugin config) stores a TOTP secret on the user (`generateTotpSecret` → `otpauthUrl` for the QR); `enroll/verify` confirms the first code, marks the user enrolled, and returns a one-time batch of **recovery codes** (shown once, stored hashed).

**Sign-in enforcement** is automatic for any enrolled user — no config flag:

1. **Challenge, not session.** When an enrolled user posts valid credentials to `POST /auth/sign-in`, the response is `{ ok: true, mfaRequired: true, pendingToken }` — a short-lived (5 min), single-use token whose hash is stored in `authTokens`. **No session cookie is issued.** The pending token is NOT a session; it can only redeem the second factor.
2. **Verify → session.** The client posts the pending token plus a `code` (6-digit TOTP) — or a `recoveryCode` as the lost-authenticator fallback — to `POST /auth/mfa/verify`. The token is redeemed atomically (single-use, so a guessed code can't be retried against the same challenge), the code is checked against the stored secret (±1 step drift, constant-time compare), and only then is the real session issued — through the same `issueSession` path as password sign-in, so **rotation, sliding-window renewal, and revocation all apply**.

A non-enrolled user still gets a session directly from `sign-in` (unchanged). Recovery codes are single-use; regenerate them with `POST /auth/mfa/recovery-codes/regenerate` (wipes the prior set) and they are wiped on `unenroll`. The TOTP secret is stored plaintext on the user row — put it behind your database's at-rest encryption.

## Passkeys / WebAuthn

Server verification (`verifyRegistration` / `verifyAssertion`, `node:crypto`-only) checks the challenge, origin, rpId hash, and signature, and enforces a strictly-increasing signature counter (clone detection). Attestation is intentionally skipped (the 90% path). The browser ceremony helper lives in the `/web` subpath:

```tsx
import { registerPasskey, signInWithPasskey, isPasskeySupported } from '@voltro/plugin-auth/web'

if (isPasskeySupported()) {
  await registerPasskey({ userId, userName }, { csrfToken })   // navigator.credentials.create
  await signInWithPasskey({ userId })                          // navigator.credentials.get
}
```

### Clone detection is atomic

Per the WebAuthn spec, an authenticator's signature counter must strictly increase across assertions; a counter that regresses (or fails to advance) means a cloned credential. The counter bump is a **store-level compare-and-swap** — `advancePasskeyCounter` issues one `UPDATE … WHERE counter < :new RETURNING` statement, so the check and the write are a single atomic operation. Two replicas racing the *same* assertion can't both succeed: exactly one `UPDATE` matches, and the loser (zero rows advanced, counter unchanged) is rejected with `counter_regressed`. The guarantee holds across replicas, not just within one process.

### Multi-replica: bring your own ChallengeStore

A passkey ceremony is two round-trips (`.../options` mints a challenge, `.../verify` redeems it). The default `memoryChallengeStore()` holds challenges in process memory — fine for a **single node**, but on ≥2 replicas the `options` and `verify` requests can land on different nodes and the challenge is missing. For any multi-replica deployment, pass a shared store:

```ts
import { authRoutesPlugin, dataStoreChallengeStore } from '@voltro/plugin-auth'

authRoutesPlugin({
  // …store, secret, passkey…
  challengeStore: dataStoreChallengeStore(sql),  // shared table, survives across replicas
})
```

`dataStoreChallengeStore(sql)` persists challenges in the `passkeyChallenges` table (shipped in `authTables`) — single-use (`take` is one `DELETE … RETURNING`, so concurrent verifies can't both redeem) and short-lived (`put` stamps `expiresAt`; an expired row reads as absent). The caller owns the `SqlClient` lifecycle, same contract as `postgresUserStore`.

**BYO contract.** Any `ChallengeStore` you supply implements two Effect-returning methods, keyed by `<userId>:<ceremony>`:

```ts
interface ChallengeStore {
  put: (key: string, challenge: string, ttlSeconds: number) => Effect.Effect<void>
  take: (key: string) => Effect.Effect<string | null>   // read AND delete (single-use); null if absent/expired
}
```

`take` **must** delete on read (single-use) and return `null` for an absent, already-taken, or expired key. Back it with any shared, low-latency store (Postgres via `dataStoreChallengeStore`, or a Redis/KV of your own) so all replicas see the same challenges.

## Multi-key session-secret rotation

To rotate without logging everyone out: set the new secret as `VOLTRO_SESSION_SECRET`, move the old one to `VOLTRO_SESSION_SECRET_PREVIOUS` for one max-session-lifetime window, then drop the `_PREVIOUS` var. Every verify path is keyed — the framework's rpc auth chain, the plugin's `/auth/*` routes, and `readSession` — trying `current` first, then `previous`. A cookie that verified under the previous key is re-issued under the current key on the next authenticated `/auth/*` response (`GET /auth/session` triggers this proactively), so live sessions migrate onto the new key during the window. The optional `VOLTRO_SESSION_KID` / `VOLTRO_SESSION_KID_PREVIOUS` vars label the keys (non-secret; default `k0` / `k-previous`). Prefer explicit config over env? `authRoutesPlugin` / `AuthConfig` accept `secrets: { current, previous? }` directly; `resolveSessionSecrets()` (in `@voltro/protocol/session`) is the env reader underneath.

## Sliding-window auto-renewal

Sessions carry an `iat`. `verifySessionKeyed` returns a `renew` flag once the session crosses the renewal threshold (default 70% of its lifetime); authenticated `/auth/*` responses act on it by re-issuing the cookie with its original lifetime (and sliding the `sessions` row's `expiresAt` forward), so an active user never gets logged out mid-session. `GET /auth/session` is the probe a client pings to keep a session sliding — rpc frames over the WebSocket can't set cookies, so renewal is an HTTP-response mechanism.

## Request-time session revocation

Revoking a session (device-list revoke, "sign out other devices", sign-out, or password-reset's revoke-all) actually terminates the live cookie: on every verify, the cookie's `metadata.sessionId` is checked against the `sessions` table through an in-process TTL cache (default **30s**, tune via `authRoutesPlugin({ sessionRevocation: { ttlMs } })`). A kill is instant on the process that performed it (inline cache invalidation) and takes effect within the cache window on other replicas. The plugin carries a pre-wired `auth.sessionStrategy` (same store, same cache) that `voltro dev` / `voltro serve` slot into the rpc auth chain automatically — so WebSocket calls reject a revoked session exactly like the HTTP routes. A cookie minted by hand via `issueSession` (no `sessions` row, no `sessionId`) stays purely stateless and can't be revoked this way.

## Post-authentication subject guards

Every login path (password, MFA, magic-link, passkey) resolves WHO you are, then — right before minting the session cookie — runs the configured **subject guards** against the authenticated `UserRecord`. A guard returns `{ ok: true }` to allow or `{ ok: false, code, message }` to VETO; the first rejection wins, the login returns a **403** carrying the guard's `code` as the body `error`, and **no session is issued**. Sign-up is exempt — it creates a brand-new user no guard could yet reject.

This is a general seam, not tied to any single concern — "account deactivated", "email not verified", "tenant suspended", "must accept new terms" all fit. Pass guards on `subjectGuards`:

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

const suspendedTenants = new Set(['tenant-under-review'])

const tenantSuspendedGuard: SubjectGuard = (user) =>
  Effect.succeed(
    suspendedTenants.has(user.tenantId)
      ? { ok: false, code: 'tenant_suspended', message: 'tenant is suspended' }
      : { ok: true },
  )

authRoutesPlugin({
  store,
  secret: process.env.VOLTRO_SESSION_SECRET!,
  defaultTenantId: 'acme',
  subjectGuards: [tenantSuspendedGuard],
})
```

The canonical guard ships in `@voltro/plugin-deactivation`: `deactivationGuard()` refuses login when the user's `deactivatedAt` is set — making the `deactivation()` mixin's "a deactivated user can't log in" promise self-enforcing without a hand-rolled resolver check. See [deactivation](/docs/plugins/deactivation#enforcing-a-deactivated-user-cant-log-in). With no guards configured, every authenticated user proceeds exactly as before.

## Rehash-on-verify

`verifyPasswordWithRehash` parses the scrypt cost out of the stored hash; when it's below the current cost, a successful sign-in returns a freshly-minted replacement that `handleSignIn` persists via `UserStore.updatePassword` — passwords strengthen silently on the next login, no forced reset.

## When to use a different auth strategy

`plugin-auth`'s password flow is the default. For specific needs:

- **Existing user base in an IdP** (Okta, Auth0, Clerk, WorkOS, Kinde, Supabase) — add the matching [external IdP strategy](/docs/authentication/external-idp) to the chain. The framework keeps its own `Subject` + `tenantId`.
- **Any OIDC provider** — `@voltro/plugin-auth-oidc`'s `oidcStrategy`, or `jwtBearerStrategy` from `@voltro/protocol/jwt` directly.
- **Headless / programmatic callers** — `apiKeyStrategy` from `@voltro/protocol/apikey`.

For 95% of apps, the password flow is the right starting point. The depth lives in the Authentication section.
