Skip to content

Build a complete SaaS auth in 30 minutes

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 begin
  • Node 18+ and pnpm
  • An IntelliAuth tenant where you can register applications and groups
  • A few minutes of patience with config copy-paste
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

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

Terminal window
mkdir team-todos && cd team-todos
mkdir frontend backend
Terminal window
cd frontend
pnpm create vite@latest . --template react-ts
pnpm install
pnpm add @intelliauth/react-sdk
Terminal window
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"
}
}

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:

Terminal window
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:

Terminal window
curl http://localhost:4000/api/todos
# {"error":"unauthorized","message":"Bearer token missing", ...}

Good — auth is wired.

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: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:

Terminal window
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).

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.

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.

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

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