Skip to main content
AuthProvider gives your app session management: sign-in, sign-up, sign-out, token refresh, MFA, and SSR hydration. Wrap your app, use useAuth() to read state, and use form(auth.signIn) for login — same patterns as the rest of the framework.

Quick start

1. Wrap your app

import { AuthProvider } from 'vertz/ui/auth';

function App() {
  return (
    <AuthProvider>
      <AppContent />
    </AuthProvider>
  );
}

2. Build a login form

import { useAuth } from 'vertz/ui/auth';
import { form } from 'vertz/ui';

function LoginPage() {
  const auth = useAuth();

  const loginForm = form(auth.signIn, {
    onSuccess: () => navigate({ to: '/dashboard' }),
  });

  return (
    <form action={loginForm.action} method={loginForm.method} onSubmit={loginForm.onSubmit}>
      <input name={loginForm.fields.email} type="email" />
      <span>{loginForm.email.error}</span>

      <input name={loginForm.fields.password} type="password" />
      <span>{loginForm.password.error}</span>

      <button type="submit" disabled={loginForm.submitting}>
        Sign In
      </button>
    </form>
  );
}

3. Read auth state anywhere

import { useAuth } from 'vertz/ui/auth';

function Header() {
  const auth = useAuth();

  return (
    <header>
      {auth.isAuthenticated ? (
        <span>Welcome, {auth.user.email}</span>
      ) : (
        <a href="/login">Sign in</a>
      )}
    </header>
  );
}
auth.signIn is an SdkMethodform() works with it directly for validation, submission, and error handling. No manual fetch calls, no state wiring.

How it works

Auth state machine

idle → loading → authenticated | unauthenticated | mfa_required | error
StatusMeaning
idleInitial state before any auth check (SSR/Node only)
loadingAuth operation in progress
authenticatedValid session, auth.user is populated
unauthenticatedNo valid session
mfa_requiredSign-in succeeded but MFA verification needed
errorAuth operation failed, auth.error has details

JWT session lifecycle

Vertz uses httpOnly cookies for JWT tokens — the client never reads the token directly. The server returns expiresAt in the response body, and the client schedules proactive refresh:
signIn/signUp → { user, expiresAt } → schedule refresh at expiresAt - 10s → POST /refresh → repeat
Token refresh is automatic:
  • Scheduled 10 seconds before expiry
  • Deduplicated (concurrent calls share one in-flight request)
  • Deferred when the tab is hidden (refreshes on focus if stale)
  • Deferred when offline (refreshes on reconnect)

API reference

AuthProvider

Wraps your app with auth context. All useAuth() calls must be inside an AuthProvider.
<AuthProvider basePath="/api/auth" accessControl>
  <App />
</AuthProvider>
PropTypeDefaultDescription
basePathstring'/api/auth'Base URL for auth endpoints
accessControlbooleanfalseEnable automatic access set management
childrenunknownApp content

useAuth()

Returns reactive auth state. All signal properties are auto-unwrapped by the compiler — no .value needed.
const auth = useAuth();
PropertyTypeDescription
userUser | nullCurrent user or null
statusAuthStatusCurrent auth state
isAuthenticatedbooleantrue when status is 'authenticated'
isLoadingbooleantrue when status is 'loading'
errorAuthClientError | nullLast auth error
signInSdkMethodSign in with email/password
signUpSdkMethodCreate account with email/password
signOut() => Promise<void>Clear session and cookies
refresh() => Promise<void>Manually refresh the token
mfaChallengeSdkMethodSubmit MFA TOTP code
forgotPasswordSdkMethodRequest password reset email
resetPasswordSdkMethodReset password with token

AuthGate

Gates rendering on auth state resolution. Shows fallback while auth is loading, children once resolved.
import { AuthGate } from 'vertz/ui/auth';

<AuthGate fallback={() => <LoadingScreen />}>
  <App />
</AuthGate>;

ProtectedRoute

Route guard that handles loading, authentication, entitlements, and redirect — all in one component. Wraps useAuth(), the router, and can() so you don’t have to.
import { ProtectedRoute } from 'vertz/ui/auth';

// In route definitions:
'/dashboard': {
  component: () => (
    <ProtectedRoute loginPath="/login" fallback={() => <LoadingSpinner />}>
      <Dashboard />
    </ProtectedRoute>
  ),
}
PropTypeDefaultDescription
loginPathstring'/login'Where to redirect unauthenticated users
fallback() => unknownnullRendered while auth is resolving
childrenunknownRendered when authenticated
requiresEntitlement[]Entitlements the user must have (checked via can()). Type-safe when codegen is active.
forbidden() => unknownnullRendered when authenticated but lacking entitlements
returnTobooleantrueAppend ?returnTo=<currentPath> to the login redirect
Behavior:
Auth stateResult
idle / loadingRenders fallback
authenticated (entitlements met)Renders children
authenticated (entitlements denied)Renders forbidden (no redirect)
unauthenticated / error / mfa_requiredNavigates to loginPath
ProtectedRoute does NOT redirect when entitlements fail — it renders forbidden instead. This avoids redirect loops where a logged-in user is sent to login but still lacks permissions after signing in.
With entitlement checks:
'/admin': {
  component: () => (
    <ProtectedRoute
      requires={['admin:manage']}
      forbidden={() => <p>You don't have access to this page.</p>}
    >
      <AdminPanel />
    </ProtectedRoute>
  ),
}
Without a provider (fail-open): If there’s no AuthProvider in the tree, ProtectedRoute renders children and logs a dev-mode warning. This matches AuthGate’s fail-open behavior. SSR: During server rendering, ProtectedRoute renders the fallback. The redirect fires client-side after hydration.

Auth methods as SdkMethods

Every auth method (signIn, signUp, mfaChallenge, forgotPassword, resetPassword) is an SdkMethod. This means:
  1. form() works directly — validation, submission, field errors, all automatic
  2. .url and .method are available for <form action={...} method={...}>
  3. .meta.bodySchema provides the validation schema
// All of these work the same way
const loginForm = form(auth.signIn);
const signupForm = form(auth.signUp);
const mfaForm = form(auth.mfaChallenge);
const forgotForm = form(auth.forgotPassword);
const resetForm = form(auth.resetPassword);

Input types

MethodRequired fields
signIn{ email: string, password: string }
signUp{ email: string, password: string }
mfaChallenge{ code: string }
forgotPassword{ email: string }
resetPassword{ token: string, password: string }

MFA flow

When the server requires MFA, signIn transitions to mfa_required instead of authenticated:
function LoginPage() {
  const auth = useAuth();

  const loginForm = form(auth.signIn);
  const mfaForm = form(auth.mfaChallenge, {
    onSuccess: () => navigate({ to: '/dashboard' }),
  });

  // Show MFA form when required
  if (auth.status === 'mfa_required') {
    return (
      <form onSubmit={mfaForm.onSubmit}>
        <input name={mfaForm.fields.code} placeholder="Enter 6-digit code" />
        <span>{mfaForm.code.error}</span>
        <button type="submit" disabled={mfaForm.submitting}>
          Verify
        </button>
      </form>
    );
  }

  return (
    <form onSubmit={loginForm.onSubmit}>
      <input name={loginForm.fields.email} type="email" />
      <input name={loginForm.fields.password} type="password" />
      <button type="submit" disabled={loginForm.submitting}>
        Sign In
      </button>
    </form>
  );
}

Password reset flow

Request reset email

function ForgotPasswordPage() {
  const auth = useAuth();
  let submitted = false;

  const forgotForm = form(auth.forgotPassword, {
    onSuccess: () => {
      submitted = true;
    },
  });

  if (submitted) {
    return <p>Check your email for a reset link.</p>;
  }

  return (
    <form onSubmit={forgotForm.onSubmit}>
      <input name={forgotForm.fields.email} type="email" placeholder="Your email" />
      <span>{forgotForm.email.error}</span>
      <button type="submit" disabled={forgotForm.submitting}>
        Send Reset Link
      </button>
    </form>
  );
}

Reset with token

function ResetPasswordPage() {
  const auth = useAuth();
  const token = new URLSearchParams(location.search).get('token');

  const resetForm = form(auth.resetPassword, {
    onSuccess: () => navigate({ to: '/login' }),
  });

  return (
    <form onSubmit={resetForm.onSubmit}>
      <input type="hidden" name={resetForm.fields.token} value={token} />
      <input name={resetForm.fields.password} type="password" placeholder="New password" />
      <span>{resetForm.password.error}</span>
      <button type="submit" disabled={resetForm.submitting}>
        Reset Password
      </button>
    </form>
  );
}

SSR hydration

When using SSR, the server injects the session into the page so the client doesn’t need an initial /api/auth/session fetch.

Server side

import { createSessionScript } from 'vertz/ui-server';

// In your SSR handler, after validating the JWT:
const sessionScript = createSessionScript({
  user: { id: '1', email: 'user@example.com', role: 'admin' },
  expiresAt: session.expiresAt.getTime(),
});

// Include in the HTML response <head>

What happens on the client

  1. Server injects window.__VERTZ_SESSION__ with { user, expiresAt }
  2. AuthProvider reads it on initialization — no fetch needed
  3. Auth state is 'authenticated' immediately — no loading flicker
  4. Token refresh is scheduled from the hydrated expiresAt
When there’s no session (guest user), AuthProvider transitions to 'unauthenticated' immediately.

Access control integration

When accessControl is enabled, AuthProvider automatically manages the access set:
<AuthProvider accessControl>
  <App />
</AuthProvider>
This:
  • Wraps children in AccessContext.Provider
  • Fetches the access set from ${basePath}/access-set after successful auth
  • Clears the access set on sign out
  • Hydrates from window.__VERTZ_ACCESS_SET__ during SSR
Use can() anywhere inside the provider:
import { can } from 'vertz/ui/auth';

function AdminPanel() {
  const check = can('admin:manage');

  if (!check.allowed) {
    return <p>Access denied</p>;
  }

  return <AdminDashboard />;
}
See the Access Control guide for full details on can(), AccessGate, and entity-scoped checks.

Error handling

Auth errors are available via auth.error:
const auth = useAuth();

if (auth.error) {
  // auth.error has: code, message, statusCode, retryAfter?
  return <div className="error">{auth.error.message}</div>;
}

Error codes

CodeWhen
INVALID_CREDENTIALSWrong email/password
USER_EXISTSEmail already registered
MFA_REQUIREDMFA verification needed (status transitions to mfa_required)
INVALID_MFA_CODEWrong MFA code
RATE_LIMITEDToo many attempts (retryAfter is set)
NETWORK_ERRORFetch failed (offline, DNS, etc.)
SERVER_ERRORUnexpected server error

Common patterns

Logout button

function LogoutButton() {
  const auth = useAuth();

  return <button onClick={() => auth.signOut()}>Sign Out</button>;
}

Conditional rendering based on auth

function AppContent() {
  const auth = useAuth();

  return (
    <AuthGate fallback={() => <LoadingSpinner />}>
      {auth.isAuthenticated ? <Dashboard /> : <LandingPage />}
    </AuthGate>
  );
}

Sign-up with extra fields

const signupForm = form(auth.signUp);

<form onSubmit={signupForm.onSubmit}>
  <input name={signupForm.fields.email} type="email" />
  <input name={signupForm.fields.password} type="password" />
  <input name={signupForm.fields.name} />
  <button type="submit">Create Account</button>
</form>;
The signUp input accepts { email, password, ...extra }, but reserved auth fields such as role, plan, emailVerified, id, and timestamps are ignored by the auth handler.

Next steps

Multi-Tenancy

TenantProvider, useTenant(), and TenantSwitcher for multi-tenant apps.

Server-Side Auth

Configure JWT sessions, RBAC, plans, and usage limits.

Access Control

Use can() and AccessGate in your UI components.

Forms

Deep dive into form() — validation, fields, progressive enhancement.

SSR

Server-side rendering setup and hydration.