This is the longest topic in the developer docs. By the end of it, you have a working web app — React frontend, Node backend, IntelliAuth between them — with sign-in, authenticated API calls, an admin role, and a basic user-list page. Total elapsed time: about 30 minutes if everything works, 45 if you stop to understand each step.
We'll keep the scope small: a "team todos" app. Anyone signed in can read todos. Members of a "Team admins" group can create and delete todos. That's enough to exercise auth, scopes, and role-gated routes — which is the full surface most production SaaS apps need.
- Node 18+ and pnpm
- An IntelliAuth tenant where you can register applications and groups
- A few minutes of patience with config copy-paste
The plan
Section titled “The plan”team-todos/├── frontend/ React + Vite + @intelliauth/react-sdk├── backend/ Express + @intelliauth/node-sdk└── README.mdThe frontend signs the user in and reads / writes todos. The backend validates the access token, checks scopes, and stores todos in memory (we'll skip a real database for brevity).
Step 1 — Register the applications in IntelliAuth
Section titled “Step 1 — Register the applications in IntelliAuth”Sign into your tenant admin console. Create two applications:
Application 1: Web SPA (the frontend)
- Type: SPA.
- Redirect URIs:
http://localhost:5173/callback(Vite default). - Allowed origins:
http://localhost:5173. - Audience:
https://api.cymmetri.local(use the same string in the backend later).
Save. Copy the client ID. There is no client secret for SPAs.
Application 2: M2M (the backend's outbound calls — we won't actually use this in the tutorial, but you'll need it for production)
- Type: Machine-to-machine.
- Allowed scopes:
users:read,audit:read.
Save. Copy the client ID + secret. Skip if you don't want this yet.
Create a group: Team admins, with scopes todos:write (we'll register that scope on the API side in a moment).
Register a custom scope todos:write on the tenant (Authentication → Scopes → New scope). Description: "Create and delete todos."
Step 2 — Scaffold
Section titled “Step 2 — Scaffold”mkdir team-todos && cd team-todosmkdir frontend backendFrontend
Section titled “Frontend”cd frontendpnpm create vite@latest . --template react-tspnpm installpnpm add @intelliauth/react-sdkBackend
Section titled “Backend”cd ../backendpnpm initpnpm add express @intelliauth/node-sdkpnpm add -D typescript @types/express @types/node ts-nodeCreate backend/tsconfig.json:
{ "compilerOptions": { "target": "es2022", "module": "node16", "moduleResolution": "node16", "strict": true, "esModuleInterop": true, "outDir": "dist" }}Step 3 — The backend
Section titled “Step 3 — The backend”Create backend/src/index.ts:
import express from 'express'import { intelliAuth } from '@intelliauth/node-sdk/express'
const app = express()app.use(express.json())
app.use(intelliAuth({ tenantUrl: process.env.INTELLIAUTH_TENANT_URL!, audience: 'https://api.cymmetri.local',}))
interface Todo { id: number; text: string; done: boolean; created_by: string }const todos: Todo[] = []let nextId = 1
// Anyone signed in can list.app.get('/api/todos', (req, res) => { res.json({ data: todos })})
// Anyone signed in can mark done.app.patch('/api/todos/:id', (req, res) => { const t = todos.find((t) => t.id === Number(req.params.id)) if (!t) return res.status(404).json({ error: 'not_found' }) t.done = !!req.body.done res.json({ data: t })})
// Team admins can create.app.post('/api/todos', intelliAuth.requireScope('todos:write'), (req, res) => { const t = { id: nextId++, text: req.body.text, done: false, created_by: req.user!.sub } todos.push(t) res.json({ data: t }) },)
// Team admins can delete.app.delete('/api/todos/:id', intelliAuth.requireScope('todos:write'), (req, res) => { const i = todos.findIndex((t) => t.id === Number(req.params.id)) if (i < 0) return res.status(404).json({ error: 'not_found' }) todos.splice(i, 1) res.status(204).end() },)
const port = Number(process.env.PORT ?? 4000)app.listen(port, () => console.log(`API on http://localhost:${port}`))Run it:
INTELLIAUTH_TENANT_URL=https://banking-cymmetri.intelliauth.local pnpm ts-node src/index.tsTest the public path with curl. The platform's middleware will reject without a token:
curl http://localhost:4000/api/todos# {"error":"unauthorized","message":"Bearer token missing", ...}Good — auth is wired.
Step 4 — The frontend
Section titled “Step 4 — The frontend”Edit frontend/src/main.tsx:
import { IntelliAuthProvider } from '@intelliauth/react-sdk'import { createRoot } from 'react-dom/client'import App from './App'
createRoot(document.getElementById('root')!).render( <IntelliAuthProvider tenantUrl={import.meta.env.VITE_INTELLIAUTH_TENANT_URL} clientId={import.meta.env.VITE_INTELLIAUTH_CLIENT_ID} redirectUri={`${window.location.origin}/callback`} audience="https://api.cymmetri.local" scope="openid profile email offline_access todos:write" > <App /> </IntelliAuthProvider>,)Note scope includes todos:write — the SPA can ask for it, but the platform only includes it in the issued token if the user is in the Team admins group.
Create frontend/.env.local:
VITE_INTELLIAUTH_TENANT_URL=https://banking-cymmetri.intelliauth.localVITE_INTELLIAUTH_CLIENT_ID=app_01HZX...Rewrite frontend/src/App.tsx:
import { useEffect, useState } from 'react'import { useIntelliAuth } from '@intelliauth/react-sdk'
interface Todo { id: number; text: string; done: boolean; created_by: string }
export default function App() { const { user, loading, scopes, loginWithRedirect, logout, getAccessToken } = useIntelliAuth() const [todos, setTodos] = useState<Todo[]>([]) const [newText, setNewText] = useState('')
const canWrite = scopes?.includes('todos:write') ?? false
useEffect(() => { if (user) refresh() }, [user])
async function refresh() { const token = await getAccessToken() const res = await fetch('http://localhost:4000/api/todos', { headers: { 'Authorization': `Bearer ${token}` }, }) const body = await res.json() setTodos(body.data) }
async function addTodo() { const token = await getAccessToken() await fetch('http://localhost:4000/api/todos', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: newText }), }) setNewText('') refresh() }
async function toggleDone(t: Todo) { const token = await getAccessToken() await fetch(`http://localhost:4000/api/todos/${t.id}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ done: !t.done }), }) refresh() }
async function deleteTodo(t: Todo) { const token = await getAccessToken() await fetch(`http://localhost:4000/api/todos/${t.id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` }, }) refresh() }
if (loading) return <p>Loading…</p> if (!user) return <button onClick={() => loginWithRedirect()}>Sign in</button>
return ( <main style={{ maxWidth: 600, margin: '2rem auto' }}> <header style={{ display: 'flex', justifyContent: 'space-between' }}> <span>Hi, {user.name ?? user.email}</span> <button onClick={() => logout()}>Sign out</button> </header> <h1>Team todos</h1> {canWrite && ( <p style={{ display: 'flex', gap: '0.5rem' }}> <input value={newText} onChange={(e) => setNewText(e.target.value)} placeholder="New todo" /> <button onClick={addTodo}>Add</button> </p> )} <ul> {todos.map((t) => ( <li key={t.id}> <input type="checkbox" checked={t.done} onChange={() => toggleDone(t)} /> {t.text} {canWrite && <button onClick={() => deleteTodo(t)} style={{ marginLeft: '1rem' }}>Delete</button>} </li> ))} </ul> </main> )}Run the frontend:
pnpm devOpen http://localhost:5173. Click sign in. You should be redirected to the IntelliAuth tenant, sign in with your tenant account, redirected back. The todos list loads (empty).
Step 5 — Make yourself a Team admin
Section titled “Step 5 — Make yourself a Team admin”Sign in to the tenant admin console with an admin user. Go to Users → find your test user → add to "Team admins" group.
Sign out of the frontend, sign back in. Now you should see the "Add" form. Create a todo. Toggle done. Delete it. All via the React app, talking to the Node backend, with IntelliAuth validating every step.
Step 6 — Try without the role
Section titled “Step 6 — Try without the role”Open an incognito window, sign in as a different user (one not in Team admins). The "Add" form is hidden because scopes doesn't include todos:write. If they hit the API directly, the backend's requireScope('todos:write') middleware returns 403.
Frontend hides the affordance, backend enforces the rule. Both checks matter — the frontend hide is for UX, the backend check is for security.
What you just built
Section titled “What you just built”- An OAuth-protected SPA with a real authorization-code-with-PKCE flow.
- A backend API that validates every request's bearer token against the IntelliAuth tenant.
- A role boundary expressed through scopes, surfaced on the frontend via
scopes, enforced on the backend via middleware. - Silent refresh keeping the access token fresh while the user works.
Everything else in a production SaaS — branding, MFA enrolment, audit, billing — is config in the tenant admin console plus a few small frontend additions.
What's missing (deliberately)
Section titled “What's missing (deliberately)”- A real database. Replace the in-memory
todos[]with whatever your stack uses. - Branding. The IntelliAuth sign-in page is the platform default. Open Branding in the admin console; upload your logo + theme.
- MFA. Turn it on in the authentication policy. With MFA required, your code doesn't change — the SDK handles the prompt inline.
- Webhooks. When users sign up, you probably want a CRM row. Set up a webhook subscription on
user.signed_up.
What to read next
Section titled “What to read next”- Refresh token rotation — what's happening behind the scenes when the SDK refreshes.
- Step-up authentication — for the "are you sure?" actions in your app.
- Webhook setup — to react when things happen in the tenant.