Skip to content

Tutorial: React + FastAPI

Build a Team Notes app with FastAPI and React that uses all three Sentinel authorization tiers: workspace roles, custom RBAC, and entity ACLs.

What you'll build: A note-taking API where authenticated users list notes (Tier 1), editors create notes (Tier 1), users with a custom role export notes (Tier 2), and per-note view/edit permissions are checked at the entity level (Tier 3).

Mode: AuthZ (recommended). The frontend authenticates with the IdP directly. Sentinel issues an authorization token. Both tokens travel on every request.

Prerequisites

  • Sentinel running on :9003 (Getting Started)
  • A service app created in the admin panel (you need the sk_... key)
  • A client app registered with redirect URI http://localhost:5173/auth/callback
  • Google OAuth client ID
  • Python 3.12+, Node.js 18+

Step 1: Backend Setup

mkdir -p team-notes/backend && cd team-notes/backend
uv init && uv add fastapi uvicorn pydantic-settings sentinel-auth-sdk

The Sentinel class is the single entry point -- it creates middleware, permission/role clients, and a lifespan handler.

# backend/config.py
from sentinel_auth import Sentinel

sentinel = Sentinel(
    base_url="http://localhost:9003",
    service_name="team-notes",
    service_key="sk_your_key_here",
    mode="authz",
    idp_jwks_url="https://www.googleapis.com/oauth2/v3/certs",
    actions=[
        {"action": "notes:export", "description": "Export notes as JSON"},
    ],
)
# backend/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import sentinel

app = FastAPI(title="Team Notes", lifespan=sentinel.lifespan)
sentinel.protect(app, exclude_paths=["/health", "/docs", "/openapi.json"])

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

sentinel.protect(app) adds AuthzMiddleware, which validates two tokens per request:

  • Authorization: Bearer <idp_token> -- proves identity
  • X-Authz-Token: <authz_token> -- proves authorization (issued by Sentinel)

After validation, request.state.user is an AuthenticatedUser with user_id, email, workspace_id, workspace_role, etc.

Step 2: Note Model

In-memory storage for simplicity:

# backend/models.py
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime

@dataclass
class Note:
    id: uuid.UUID
    title: str
    content: str
    workspace_id: uuid.UUID
    owner_id: uuid.UUID
    owner_name: str
    created_at: datetime = field(default_factory=lambda: datetime.now(UTC))

_notes: dict[uuid.UUID, Note] = {}

def list_by_workspace(wid): return [n for n in _notes.values() if n.workspace_id == wid]
def get(nid):               return _notes.get(nid)
def create(**kw):            n = Note(id=uuid.uuid4(), **kw); _notes[n.id] = n; return n

Step 3: Routes -- All Three Tiers

# backend/routes.py
import uuid
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sentinel_auth.dependencies import get_current_user, get_workspace_id, require_role
from sentinel_auth.types import AuthenticatedUser
from config import sentinel
import models

router = APIRouter()

class CreateNoteRequest(BaseModel):
    title: str
    content: str

def get_token(request: Request) -> str:
    return request.state.token

Tier 1 -- List notes (any authenticated user):

@router.get("/notes")
async def list_notes(workspace_id: uuid.UUID = Depends(get_workspace_id)):
    return [asdict(n) for n in models.list_by_workspace(workspace_id)]

Tier 1 + 3 -- Create note (editor+) and register for entity ACLs:

@router.post("/notes", status_code=201)
async def create_note(
    body: CreateNoteRequest,
    user: AuthenticatedUser = Depends(require_role("editor")),
):
    note = models.create(
        title=body.title, content=body.content,
        workspace_id=user.workspace_id,
        owner_id=user.user_id, owner_name=user.name,
    )
    await sentinel.permissions.register_resource(
        resource_type="note", resource_id=note.id,
        workspace_id=user.workspace_id, owner_id=user.user_id,
        visibility="workspace",
    )
    return asdict(note)

require_role("editor") enforces the hierarchy: viewer < editor < admin < owner.

Tier 2 -- Export notes (requires notes:export RBAC action):

@router.get("/notes/export")
async def export_notes(
    user: AuthenticatedUser = Depends(sentinel.require_action("notes:export")),
    workspace_id: uuid.UUID = Depends(get_workspace_id),
):
    return {"notes": [asdict(n) for n in models.list_by_workspace(workspace_id)]}

An admin must create a role with notes:export and assign it to users via the admin panel.

Tier 3 -- View a single note (entity ACL check):

@router.get("/notes/{note_id}")
async def get_note(
    note_id: uuid.UUID,
    user: AuthenticatedUser = Depends(get_current_user),
    token: str = Depends(get_token),
):
    note = models.get(note_id)
    if not note:
        raise HTTPException(status_code=404, detail="Note not found")
    allowed = await sentinel.permissions.can(
        token=token, resource_type="note",
        resource_id=note_id, action="view",
    )
    if not allowed:
        raise HTTPException(status_code=403, detail="Access denied")
    return asdict(note)

Include the router:

# Add to backend/main.py
from routes import router
app.include_router(router)

Step 4: React Frontend

npm create vite@latest frontend -- --template react-ts
cd frontend
npm install @sentinel-auth/js @sentinel-auth/react react-router-dom @tanstack/react-query

Auth Client

// src/api/client.ts
import { SentinelAuthz, IdpConfigs } from "@sentinel-auth/js";

const SENTINEL_URL = import.meta.env.VITE_SENTINEL_URL || "http://localhost:9003";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:9200";
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || "";

export const authzClient = new SentinelAuthz({
  sentinelUrl: SENTINEL_URL,
  idps: { google: IdpConfigs.google(GOOGLE_CLIENT_ID) },
});

export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
  return authzClient.fetchJson<T>(`${BACKEND_URL}${path}`, options);
}

fetchJson() attaches both tokens (Authorization + X-Authz-Token), retries on 401, and throws on errors.

Provider

// src/main.tsx
import { AuthzProvider } from "@sentinel-auth/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { authzClient } from "./api/client";

createRoot(document.getElementById("root")!).render(
  <AuthzProvider client={authzClient}>
    <QueryClientProvider client={new QueryClient()}>
      <BrowserRouter><App /></BrowserRouter>
    </QueryClientProvider>
  </AuthzProvider>,
);

Routes with AuthzGuard

// src/App.tsx
import { AuthzGuard } from "@sentinel-auth/react";
import { Route, Routes } from "react-router-dom";

export function App() {
  return (
    <Routes>
      <Route path="/auth/callback" element={<AuthCallback />} />
      <Route path="*" element={
        <AuthzGuard fallback={<Login />} loading={<div>Loading...</div>}>
          <Routes>
            <Route path="/" element={<NoteList />} />
            <Route path="/notes/:id" element={<NoteDetail />} />
          </Routes>
        </AuthzGuard>
      } />
    </Routes>
  );
}

Login

// src/pages/Login.tsx
import { useAuthz } from "@sentinel-auth/react";

export function Login() {
  const { login } = useAuthz();
  return <button onClick={() => login("google")}>Sign in with Google</button>;
}

OAuth Callback

AuthzCallback handles the IdP redirect, token exchange, workspace selection, and calls onSuccess.

// src/pages/AuthCallback.tsx
import { AuthzCallback } from "@sentinel-auth/react";
import { useNavigate } from "react-router-dom";

export function AuthCallback() {
  const navigate = useNavigate();
  return (
    <AuthzCallback
      onSuccess={() => navigate("/notes", { replace: true })}
      workspaceSelector={({ workspaces, onSelect, isLoading }) => (
        <div>
          <h2>Select Workspace</h2>
          {workspaces.map((ws) => (
            <button key={ws.id} onClick={() => onSelect(ws.id)} disabled={isLoading}>
              {ws.name} ({ws.role})
            </button>
          ))}
        </div>
      )}
    />
  );
}

Note List

// src/pages/NoteList.tsx
import { useAuthzUser } from "@sentinel-auth/react";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "../api/client";

export function NoteList() {
  const user = useAuthzUser();
  const canCreate = ["editor", "admin", "owner"].includes(user.workspaceRole);
  const { data: notes = [] } = useQuery({
    queryKey: ["notes"],
    queryFn: () => apiFetch<any[]>("/notes"),
  });

  return (
    <div>
      <h1>Notes</h1>
      {canCreate && <button>New Note</button>}
      <ul>
        {notes.map((n) => <li key={n.id}>{n.title} (by {n.owner_name})</li>)}
      </ul>
    </div>
  );
}

Step 5: Run It

# Terminal 1: backend
cd backend && uvicorn main:app --port 9200 --reload

# Terminal 2: frontend
cd frontend && npm run dev

Result

Tier What Backend API Frontend
1 - Workspace Role List notes (any user) get_workspace_id Any authenticated user
1 - Workspace Role Create notes (editor+) require_role("editor") useAuthzUser().workspaceRole
2 - Custom RBAC Export notes sentinel.require_action("notes:export") Call /notes/export
3 - Entity ACL View a single note sentinel.permissions.can(...) 403 if denied
3 - Entity ACL Register resource on create sentinel.permissions.register_resource(...) --

The complete working demo is in the demo-authz/ directory of this repository.