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.
Before you start
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
team-todos/
├── frontend/ React + Vite + @intelliauth/react-sdk
├── backend/ Express + @intelliauth/node-sdk
└── README.md
The 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
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
mkdir team-todos && cd team-todos
mkdir frontend backend
Frontend
cd frontend
pnpm create vite@latest . --template react-ts
pnpm install
pnpm add @intelliauth/react-sdk
Backend
cd ../backend
pnpm init
pnpm add express @intelliauth/node-sdk
pnpm add -D typescript @types/express @types/node ts-node
Create backend/tsconfig.json:
{
"compilerOptions": {
"target": "es2022",
"module": "node16",
"moduleResolution": "node16",
"strict": true,
"esModuleInterop": true,
"outDir": "dist"
}
}
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.ts
Test the public path with curl. The platform's middleware will reject without a token:
curl http://localhost:4003/api/todos
# {"error":"unauthorized","message":"Bearer token missing", ...}
Good — auth is wired.
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.local
VITE_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:4003/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:4003/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:4003/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:4003/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 dev
Open 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
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
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
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)
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
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.