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 calling useSubject from 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) => 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.

// 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.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.