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
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:
userStore.findByEmail(email)— fetch the user (or pretend, see timing oracle).verifyPassword(password, user.passwordHash)— always run, even on unknown email, and even when the user exists but has no stored hash.- On success:
issueSession(subject, config.secret, {...})→ set theSet-Cookieheader. - If
redirectAfteris true (form POST): 302 toAUTH_CONFIG.successRedirect. - If
redirectAfteris 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 HandlerResults rather than throwing domain errors across the wire.
handleSignUp
const result = await handleSignUp(
{ email, password, redirectAfter: true },
userStore,
AUTH_CONFIG,
)What it does:
- 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.
userStore.findByEmail(email)— if exists, return 409.hashPassword(password).userStore.insert({ email, passwordHash, tenantId: defaultTenantId }).issueSession(...)+ 302 redirect or 200 JSON{ ok: true, subject }. Any failure in this body collapses to500 { 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
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
// 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
laxin the session builder (it is not anAuthConfigfield) — which is exactly what cross-origin form POST sign-in needs.strictwould block them, but the plugin never emitsstrict.
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 HandlerResults:
| 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:
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 TOTPcodeagainst the stored secret (or a single-userecoveryCodeas the lost-authenticator fallback), and only then issues the real session via the sameissueSessionpath as password sign-in (so rotation + revocation apply). A wrong code is401, 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 viastore.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 viastore.markMfaEnrolledand returns a one-time batch ofrecoveryCodes(shown once, stored hashed viastore.replaceRecoveryCodes).handleMfaRegenerateRecoveryCodes({ userId }, store)— wipes + reissues the recovery-code set.handleMfaUnenroll({ userId }, store)— clears the secret viastore.clearMfaand 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.
const requested = await Effect.runPromise(
handleMagicLinkRequest({ email }, store, { ...config, appBaseUrl: 'https://app.example.com', sendEmail }),
) // 202 regardless of whether `email` existsSessions, 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 —
authRoutesPlugin(), the mounted route table, passkeys, rotationAuth strategies — how
AuthMiddlewareresolvesctx.subjectfrom a requestExternal identity providers — stack an IdP alongside these password handlers
User stores — the
UserStorethe handlers write throughSessions — the cookie
issueSessionmintsAuth strategies — how
AuthMiddlewareresolvesctx.subjectfrom a requestExternal identity providers — stack an IdP alongside these password handlers
User stores — the
UserStorethe handlers write throughSessions — the cookie
issueSessionmints