Microsoft Entra ID (Azure AD) authentication using OIDC and OAuth 2.0 Authorization Code Flow with PKCE.
| Aspect | Detail |
|---|---|
| Protocol | OIDC + OAuth 2.0 Authorization Code Flow with PKCE |
| Frontend | React SPA with MSAL.js v2 (popup-based) |
| Backend | FastAPI with PyJWT (RS256 signature verification) |
| Tenancy | Single-tenant (company employees only) |
| Bypass | AUTH_DISABLED=true / VITE_AUTH_DISABLED=true for dev |
Two app registrations are required in Microsoft Entra ID: one for the API and one for the SPA.
- Go to Azure Portal > Microsoft Entra ID > App registrations > New registration.
- Configure:
- Name:
CRUD API - Supported account types:
Accounts in this organizational directory only - Redirect URI: leave blank
- Name:
- Click Register.
- Note the Application (client) ID and Directory (tenant) ID.
- Go to Expose an API and set the Application ID URI (accept the default
api://<client-id>). This becomes yourAPI_AUDIENCE. - Add a scope:
- Scope name:
access_as_user - Who can consent: Admins and users
- State: Enabled
- Scope name:
- The full scope URI
api://<client-id>/access_as_userbecomes yourVITE_API_SCOPE.
Create these roles under App roles:
| Display Name | Value | Description |
|---|---|---|
| App Admin | App.Admin |
Full access including backup/restore |
| App Editor | App.Editor |
Create, edit, delete items and groups |
| App Reader | App.Reader |
Read-only access |
Assign roles to users/groups via Enterprise Applications > CRUD API > Users and groups.
- Go to App registrations > New registration.
- Configure:
- Name:
CRUD SPA - Supported account types:
Accounts in this organizational directory only - Redirect URI: Platform =
Single-page application (SPA), URI =http://localhost:5173
- Name:
- Click Register.
- Note the Application (client) ID -- this is your
VITE_CLIENT_ID.
Add all environments under Authentication > Single-page application:
http://localhost:5173(Vite dev server)http://localhost:8000(Docker local)https://your-production-domain.comhttps://your-staging-domain.com
- Go to API permissions > Add a permission > My APIs > CRUD API.
- Select Delegated permissions, check
access_as_user. - Click Grant admin consent for [Your Org].
| Value | Example | Used As |
|---|---|---|
| Tenant ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
TENANT_ID, VITE_TENANT_ID |
| API Audience | api://aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa |
API_AUDIENCE |
| API Scope | api://aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/access_as_user |
VITE_API_SCOPE |
| SPA Client ID | bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb |
VITE_CLIENT_ID |
- MSAL.js v2 with popup-based login (redirect flow is incompatible with
memoryStorage) - Authorization Code Flow with PKCE -- implicit flow is not used
- Tokens stored in memory only -- never in
localStorageorsessionStorage - Automatic silent token refresh -- MSAL handles renewal
- 401 handling -- the API client automatically triggers re-login on 401 responses
frontend/src/auth/
msalConfig.js MSAL configuration (client ID, tenant, scopes)
msalInstance.js Singleton MSAL PublicClientApplication instance
AuthProvider.jsx MsalProvider wrapper with initialization state
RequireAuth.jsx Route guard -- shows Login page if unauthenticated
roles.js RBAC role constants (ROLE_ADMIN, ROLE_EDITOR, ROLE_READER)
Set VITE_AUTH_DISABLED=true to skip MSAL entirely. When disabled:
AuthProviderrenders children directly withoutMsalProviderRequireAuthpasses through without checking authentication state- The API client sends requests without an
Authorizationheader
AuthProviderinitializes MSAL and handles any pending redirect.RequireAuthchecks if the user is authenticated.- If not authenticated, renders
Loginpage with a "Sign in with Microsoft" button. - User clicks sign in -- MSAL opens a popup to the Microsoft login page.
- Microsoft authenticates and returns an auth code.
- MSAL exchanges the auth code for tokens (PKCE-verified).
- Tokens are stored in memory.
- For each API request, the client acquires a token silently and sends it as
Authorization: Bearer <token>.
roles.js exports helper functions for checking user roles from MSAL account claims:
import { hasRole, isAdmin, ROLE_ADMIN, ROLE_EDITOR } from "./auth/roles";
// Check specific role
hasRole(account, ROLE_ADMIN, ROLE_EDITOR); // true if user has either role
// Shorthand for admin check
isAdmin(account); // true if user has App.AdminRoles are read from account.idTokenClaims.roles (an array of role value strings assigned in Entra ID).
The MSAL popup redirect target is built as a separate entry point:
// vite.config.js
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
'auth-popup': resolve(__dirname, 'auth-popup.html'),
},
},
},- JWT validation using PyJWT with RS256 cryptographic signature verification
- JWKS fetching from Microsoft's
https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys, cached in memory and refreshed on unknownkid - Claim validation:
iss,aud,exp,nbf,tid,sub - Dual issuer support: accepts both v1 (
https://sts.windows.net/{tenant}/) and v2 (https://login.microsoftonline.com/{tenant}/v2.0) issuers - Dual audience support: accepts both the Application ID URI (
api://client-id) and the raw client GUID - Tenant ID validation: token
tidclaim must match the configuredTENANT_ID - All routers require
Depends(require_auth)-- applied at the router level by the generic CRUD framework - The
/healthendpoint is unauthenticated (for Kubernetes probes)
Set AUTH_DISABLED=true to skip JWT validation. When disabled, require_auth returns a synthetic TokenPayload:
TokenPayload(
sub="dev-user",
name="Local Developer",
email="dev@localhost",
scopes=["access_as_user"],
roles=["App.Admin"],
)This gives the dev user full admin access to all endpoints.
The TokenPayload dataclass (defined in app/auth.py) contains:
| Field | Type | Source |
|---|---|---|
sub |
str |
sub claim (user identifier) |
name |
str |
name claim |
email |
str |
preferred_username or email claim |
oid |
str |
oid claim (object ID) |
tid |
str |
tid claim (tenant ID) |
scopes |
list[str] |
scp claim (space-delimited, split to list) |
roles |
list[str] |
roles claim (app roles array) |
raw |
dict |
Full decoded token payload |
All CRUD endpoints require a valid token. Additional role-based protection:
| Endpoint | Role Required |
|---|---|
POST /backup/restore |
App.Admin |
All other endpoints require authentication but no specific role.
To protect an endpoint with a specific role:
from app.auth import require_role
@router.delete(
"/{item_id}",
dependencies=[Depends(require_role("App.Admin"))],
)
async def delete_item(item_id: int):
...To require specific scopes:
from app.auth import require_scope
@router.get(
"/sensitive",
dependencies=[Depends(require_scope("access_as_user"))],
)
async def get_sensitive():
...require_role checks that the token contains at least one of the specified roles. require_scope checks that the token contains all specified scopes.
- Set
AUTH_DISABLED=truein.env(backend) -- this is the default in.env.example. - The frontend dev server (
npm run dev) does not setVITE_AUTH_DISABLEDby default. If you do not have afrontend/.envwithVITE_CLIENT_IDetc., MSAL initialization will fail. Either:- Set
VITE_AUTH_DISABLED=trueinfrontend/.env, or - Provide valid Entra IDs in
frontend/.env.
- Set
- Start backend and frontend normally. All endpoints work without tokens.
- Complete the Entra ID Configuration above.
- Backend
.env:AUTH_DISABLED=false TENANT_ID=your-tenant-id API_AUDIENCE=api://your-api-client-id CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8000 - Frontend
frontend/.env:VITE_CLIENT_ID=your-spa-client-id VITE_TENANT_ID=your-tenant-id VITE_API_SCOPE=api://your-api-client-id/access_as_user - Ensure
http://localhost:5173is a registered redirect URI in the SPA app registration. - Navigate to
http://localhost:5173and sign in.
# Auth disabled (default via .env)
docker compose up --build
# Auth enabled -- set env vars before building
AUTH_DISABLED=false TENANT_ID=xxx API_AUDIENCE=api://xxx \
docker compose up --buildNote: the Vite env vars (VITE_CLIENT_ID, etc.) are baked into the frontend at Docker build time. They are set in docker-compose.yml build args, not runtime env vars.
Auth environment variables are supplied via a ConfigMap generated inline by the GitHub Actions CD workflows:
data:
AUTH_DISABLED: "false"
TENANT_ID: "<tenant-id>"
API_AUDIENCE: "api://<api-client-id>"
CORS_ALLOWED_ORIGINS: "https://<host>"Frontend auth env vars are baked into the Docker image at build time. The build-and-push workflow reads VITE_CLIENT_ID, VITE_TENANT_ID, VITE_API_SCOPE, and VITE_AUTHORITY from the GitHub Actions environment (staging or production). Configure these under Settings > Environments > environment name > Variables.
See Deployment for full pipeline details.
Cause: Implicit grant is not enabled (this is correct -- PKCE is used instead). Fix: Ensure the redirect URI is registered as SPA type, not Web. SPA type enables PKCE automatically.
Cause: Admin consent not granted for the API permission. Fix: Go to SPA app > API permissions > click "Grant admin consent for [org]".
Cause: The app's redirect URI doesn't match what MSAL sends.
Fix: Add the exact origin (e.g. http://localhost:5173) to the SPA app's redirect URIs. The actual redirect target is {origin}/auth-popup.html but Entra only checks the origin.
Check:
TENANT_IDmatches between frontend and backend.API_AUDIENCEmatches the Application ID URI exactly.- Token hasn't expired (check
expclaim). - Clock skew between server and Microsoft.
Cause: User doesn't have the required app role. Fix: Assign the role via Enterprise Applications > Users and groups in Azure Portal.
Cause: Backend CORS doesn't allow the frontend origin.
Fix: Add the frontend URL to CORS_ALLOWED_ORIGINS env var.
Cause: MSAL couldn't refresh the token silently. Fix: The client automatically triggers re-login. If persistent, check that the SPA app registration is correctly configured.
Cause: MSAL initialization failed.
Fix: Check browser console for errors. Usually VITE_CLIENT_ID or VITE_TENANT_ID is wrong or empty. For deployed environments, verify these are set as variables in the GitHub Actions environment (staging or production).
Cause: Test client doesn't have auth override.
Fix: The test conftest.py fixture overrides require_auth with a synthetic admin user. Ensure app.dependency_overrides[require_auth] is set in your test fixture. See Testing for details.
See also: Configuration for the complete environment variable reference.