I IntelliAuth Docs

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

bash
mkdir team-todos && cd team-todos
mkdir frontend backend

Frontend

bash
cd frontend
pnpm create vite@latest . --template react-ts
pnpm install
pnpm add @intelliauth/react-sdk

Backend

bash
cd ../backend
pnpm init
pnpm add express @intelliauth/node-sdk
pnpm add -D typescript @types/express @types/node ts-node

Create backend/tsconfig.json:

json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "node16",
    "moduleResolution": "node16",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist"
  }
}

Step 3 — The backend

Create backend/src/index.ts:

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:

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

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

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:

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:

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

See also