A complete example of building a custom authentication UI with WorkOS — React frontend + Hono backend, fully typed with strict TypeScript.
This example does not use Hosted AuthKit. Instead, it implements a fully custom login flow using the WorkOS Node SDK (@workos-inc/node v8+) directly.
- Email-first login flow — enter email, check for SSO, then show password/magic code
- Password authentication —
authenticateWithPassword - Magic link authentication —
createMagicAuth+authenticateWithMagicAuth - Google OAuth — social login via
getAuthorizationUrl({ provider: 'GoogleOAuth' }) - Enterprise SSO — domain-based detection via
listConnections({ domain }), redirect to IdP - Multi-org handling — catches
organization_selection_required, shows org picker, completes withauthenticateWithOrganizationSelection - Sealed sessions —
loadSealedSession,authenticate,refresh,getLogoutUrl - CSRF protection — double-submit cookie pattern on logout and org switching
- Session refresh — transparent token refresh via
withAuthmiddleware
- Node.js 22+
- pnpm
- A WorkOS account with:
- An API key and Client ID
- A redirect URI configured:
http://localhost:5176/api/auth/callback - (Optional) Google OAuth enabled under Social Login
- (Optional) An SSO connection configured for a domain
pnpm install
cp .env.example .envFill in your .env:
WORKOS_API_KEY=sk_test_...
WORKOS_CLIENT_ID=client_...
WORKOS_COOKIE_PASSWORD=<32+ character secret>
Generate a cookie password:
openssl rand -base64 32Start both the backend (port 3001) and frontend (port 5176):
pnpm devOr run them separately:
# Terminal 1 — backend
pnpm dev:server
# Terminal 2 — frontend
pnpm dev:clientOpen http://localhost:5176.
| Script | Description |
|---|---|
pnpm dev |
Start backend + frontend concurrently |
pnpm dev:server |
Start Hono backend with tsx watch |
pnpm dev:client |
Start Vite dev server |
pnpm build |
Production build (frontend) |
pnpm check |
Run typecheck + lint + format check + tests |
pnpm typecheck |
Type-check frontend and server |
pnpm test |
Run tests |
pnpm lint |
Lint with oxlint |
pnpm format |
Format with oxfmt |
- User enters their email and clicks Continue
- Backend checks the email domain against WorkOS SSO connections (
POST /api/auth/check-email) - If an active SSO connection exists → redirect to the identity provider
- Otherwise → reveal password field and magic code option
- On successful auth, a sealed session cookie is set
The backend uses WorkOS session helpers:
loadSealedSession— decrypt the session cookiesession.authenticate()— validate the access tokensession.refresh()— get a new access token using the refresh tokensession.getLogoutUrl()— get the WorkOS logout URL
The withAuth middleware handles the full lifecycle: authenticate → check reason → refresh if expired → update cookie.
The app handles these WorkOS authentication errors:
organization_selection_required— user belongs to multiple orgs, show pickersso_required— domain requires SSO, redirect to IdP (fallback if domain check missed it)
The frontend is built with Radix Themes — a set of accessible, themeable components that handle dark mode, spacing, typography, and interactive states out of the box. The app uses appearance="dark" with the iris accent color, configured in main.tsx:
<Theme appearance="dark" accentColor="iris" radius="medium" scaling="100%">
<App />
</Theme>Components like Card, Button, TextField, Callout, Badge, Spinner, and Separator come directly from @radix-ui/themes. A small app.css file handles page layout and a few custom elements (Google OAuth button, org picker cards) using Radix CSS variables for theme consistency.
├── server.ts # Hono backend — all auth endpoints
├── server.test.ts # Backend tests (vitest + Hono app.request())
├── src/
│ ├── App.tsx # React frontend — login, org picker, dashboard
│ ├── api.ts # Fetch wrapper with CSRF handling
│ ├── types.ts # Shared TypeScript interfaces
│ ├── hooks/useAuth.ts # Auth state management hook
│ ├── components/ # Shared UI components
│ ├── views/ # View components (Login, MagicCode, OrgPicker, Dashboard)
│ ├── vite-env.d.ts # Vite client type declarations
│ ├── app.css # Page layout + Radix theme overrides
│ └── main.tsx # React entry point (Radix Theme provider)
├── tsconfig.json # Solution root (references app + server)
├── tsconfig.base.json # Shared TypeScript options
├── tsconfig.app.json # Frontend config (DOM, JSX)
├── tsconfig.server.json # Backend config (Node types, no DOM)
├── vite.config.ts # Vite + Vitest config with proxy to backend
├── index.html # Vite HTML shell
├── .env.example # Environment variable template
└── package.json