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:
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
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 callinguseSubjectfrom outside a guarded subtree).
Use inside <RequireAuth> blocks or pages you've already gated.
useOptionalSubject
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:
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) => ReactNoderender-prop. The render-prop gives you typed Subject access without an extrauseSubjectcall. fallbackdefaults tonull— 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.
// 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.
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).
<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
<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:
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 — rebinds the connection's Subject so active subscriptions re-scope to the new tenant without a reconnect. No re-auth.
Anti-patterns
- Reading
document.cookiefor the session. It'sHttpOnly— you can't. UseuseSubject/useOptionalSubject. - Storing the subject in
localStoragefor "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.