Back
View source
Cloud Engineering··16 min

Azure Auth Series — Blog 2: Protected API with FastAPI

Build a FastAPI backend that validates Azure AD JWT tokens using JWKS, expose custom API scopes, and call protected endpoints from a Next.js frontend. Complete with CRUD task management and per-user data isolation.

Azure Auth Series — Blog 2: Protected API with FastAPI#

In Blog 1, we signed users in with Microsoft Entra ID and called the Graph API directly from the browser. That works for reading Microsoft 365 data, but what about your own backend? How do you build an API that only authenticated users can access?

In this blog, we'll build a FastAPI backend that:

  • 🔑 Validates JWT tokens from Microsoft Entra ID using JWKS (JSON Web Key Sets)
  • 🎯 Exposes a custom API scope (access_as_user) that the frontend requests
  • 📋 Protects CRUD endpoints for task management — create, read, update, delete
  • 👤 Isolates data per user using the oid claim from the token
  • 🔗 Connects to the Next.js frontend we built in Blog 1

The frontend acquires an access token for your API (not the Graph API), sends it as a Bearer token, and the backend verifies the signature, audience, issuer, and expiry before processing the request.


The Azure Auth Series#

BlogTopicWhat You'll Learn
1. Basic LoginFrontend authSign in with Microsoft Entra ID
2. Protected APIYou are hereBuild a FastAPI backend that validates tokens
3. RBACAuthorizationControl access based on user roles
4. Managed IdentityZero secretsDeploy to Azure without storing credentials
5. Multi-TenantOrganization supportLet users from any org sign in
6. Service-to-ServiceOBO + Client CredentialsAuthenticate services to each other
7. API GatewayAPIMCentralize auth with Azure API Management

What We're Building#

Architecture#

Browser (Next.js + MSAL)
   |
   |  1. User signs in (popup OAuth flow)
   |  2. Frontend acquires token with scope:
   |     api://<api-client-id>/access_as_user
   |
   |  Authorization: Bearer <jwt-token>
   v
FastAPI Backend
   |--- GET  /api/me          --> user profile from token claims
   |--- GET  /api/tasks       --> list tasks for this user
   |--- POST /api/tasks       --> create a task
   |--- PATCH /api/tasks/:id  --> toggle/update a task
   |--- DELETE /api/tasks/:id --> delete a task
   |
   |  3. Backend extracts Bearer token
   |  4. Downloads JWKS from Microsoft
   |  5. Verifies RS256 signature, audience, issuer, expiry
   |  6. Extracts user claims (oid, name, email)
   v
Microsoft Entra ID (JWKS endpoint)
   https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys

What Changed from Blog 1#

Blog 1Blog 2
Frontend calls Graph API directlyFrontend calls your own FastAPI backend
Token scope: User.ReadToken scope: api://{id}/access_as_user
No backendFastAPI with JWT validation
Profile from Microsoft Graph /meProfile from token claims
Read-onlyFull CRUD (task management)

Live Demo#

Landing Page Same polished landing page from Blog 1 — the frontend authenticates identically

Authenticated State After sign-in, the CTA changes to "Go to Dashboard" and the avatar appears in the navbar


Key Concepts#

Two App Registrations#

In Blog 1 we had one app registration (the SPA frontend). Now we need two:

RegistrationPurposeType
API appRepresents the FastAPI backend. Exposes a custom scope.Web API
SPA appRepresents the Next.js frontend. Requests the API scope.Single-page app

This separation is important: the frontend and backend are different applications with different client IDs. The frontend asks Entra ID for a token scoped to the API, and the backend verifies that the token was issued for it.

Custom API Scopes#

Instead of requesting User.Read (a Microsoft Graph scope), the frontend now requests a custom scope we define:

api://<api-client-id>/access_as_user

This tells Entra ID: "I want a token that lets me access this specific API on behalf of the signed-in user." The backend then checks the token's audience claim matches its own client ID.

JWT Validation with JWKS#

The backend doesn't call Microsoft to validate each request. Instead:

  1. It downloads the JSON Web Key Set (JWKS) from Microsoft once
  2. Uses the public RSA keys to verify the token's signature locally
  3. Checks the audience (is this token meant for my API?)
  4. Checks the issuer (did Microsoft issue this?)
  5. Checks the expiry (is this token still valid?)

This is fast, stateless, and scalable — no per-request calls to Entra ID.


Step 1: Register Both Apps in Azure#

The setup script creates both registrations with a single command:

az login
./setup.sh                        # defaults: "Blog2-API" + "Blog2-Frontend"
./setup.sh MyAPI MyFrontend       # or pass custom names

What the Script Does#

#!/usr/bin/env bash
set -euo pipefail

API_APP_NAME="${1:-Blog2-API}"
SPA_APP_NAME="${2:-Blog2-Frontend}"
REDIRECT_URI="http://localhost:3000"
SCOPE_NAME="access_as_user"
GRAPH_API="00000003-0000-0000-c000-000000000000"
USER_READ="e1fe6dd8-ba31-4d61-89e7-88639da4683d"

TENANT_ID=$(az account show --query tenantId --output tsv)

# ── 1. API app registration ────────────────────────

echo "==> Creating API app registration: $API_APP_NAME"
API_APP_ID=$(az ad app create \
  --display-name "$API_APP_NAME" \
  --query appId --output tsv)

az ad sp create --id "$API_APP_ID" --output none

# Set the identifier URI (this becomes the audience)
az ad app update --id "$API_APP_ID" \
  --identifier-uris "api://$API_APP_ID"

# Expose a custom scope
SCOPE_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$API_OBJ_ID" \
  --headers "Content-Type=application/json" \
  --body "{
    \"api\": {
      \"oauth2PermissionScopes\": [{
        \"adminConsentDescription\": \"Access API on behalf of user\",
        \"adminConsentDisplayName\": \"Access API as user\",
        \"id\": \"$SCOPE_ID\",
        \"isEnabled\": true,
        \"type\": \"User\",
        \"value\": \"$SCOPE_NAME\"
      }]
    }
  }"

# ── 2. SPA frontend app registration ───────────────

SPA_APP_ID=$(az ad app create \
  --display-name "$SPA_APP_NAME" \
  --required-resource-accesses "[
    {
      \"resourceAppId\": \"$GRAPH_API\",
      \"resourceAccess\": [{
        \"id\": \"$USER_READ\",
        \"type\": \"Scope\"
      }]
    },
    {
      \"resourceAppId\": \"$API_APP_ID\",
      \"resourceAccess\": [{
        \"id\": \"$SCOPE_ID\",
        \"type\": \"Scope\"
      }]
    }
  ]" \
  --query appId --output tsv)

# Set SPA redirect URI
az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$SPA_OBJ_ID" \
  --body "{\"spa\":{\"redirectUris\":[\"$REDIRECT_URI\"]}}"

# ── 3. Write env files ─────────────────────────────

cat > backend/.env <<EOF
AZURE_TENANT_ID=$TENANT_ID
AZURE_API_CLIENT_ID=$API_APP_ID
EOF

cat > frontend/.env.local <<EOF
NEXT_PUBLIC_AZURE_CLIENT_ID=$SPA_APP_ID
NEXT_PUBLIC_AZURE_TENANT_ID=$TENANT_ID
NEXT_PUBLIC_API_SCOPE=api://$API_APP_ID/$SCOPE_NAME
NEXT_PUBLIC_API_URL=http://localhost:8000
EOF

Three key things happen:

  1. API app gets an identifier URI (api://{clientId}) and exposes the access_as_user scope
  2. SPA app is granted permission to request both User.Read (Graph) and access_as_user (our API)
  3. Environment files are written for both backend and frontend

Step 2: Project Structure#

02_protected_api/
├── setup.sh                     # Creates both Azure AD app registrations
├── cleanup.sh                   # Removes both registrations
├── backend/
│   ├── main.py                  # FastAPI app with protected endpoints
│   ├── auth.py                  # JWT validation with JWKS
│   ├── config.py                # Environment configuration
│   ├── requirements.txt         # Python dependencies
│   └── .env.example
└── frontend/
    ├── src/
    │   ├── config/
    │   │   └── auth-config.ts   # MSAL config + API scope (CHANGED)
    │   ├── hooks/
    │   │   └── use-api.ts       # Token acquisition + API calls (NEW)
    │   ├── lib/
    │   │   └── api.ts           # Typed API client with authFetch (NEW)
    │   └── app/
    │       └── dashboard/
    │           └── page.tsx     # Dashboard with task management (CHANGED)
    └── .env.local.example

Files marked (NEW) or (CHANGED) are what's different from Blog 1. The auth components (msal-provider.tsx, login-button.tsx, etc.) are identical.


Step 3: The FastAPI Backend#

Configuration#

File: backend/config.py

import os
from dotenv import load_dotenv

load_dotenv()

TENANT_ID = os.getenv("AZURE_TENANT_ID", "")
API_CLIENT_ID = os.getenv("AZURE_API_CLIENT_ID", "")

AUDIENCE = f"api://{API_CLIENT_ID}"

AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
JWKS_URI = f"{AUTHORITY}/discovery/v2.0/keys"
ISSUER = f"https://sts.windows.net/{TENANT_ID}/"
VariableValuePurpose
AUDIENCEapi://{clientId}Must match the token's aud claim
JWKS_URIlogin.microsoftonline.com/{tenant}/discovery/v2.0/keysPublic keys for signature verification
ISSUERhttps://sts.windows.net/{tenant}/Must match the token's iss claim

JWT Token Validation#

This is the core of the backend — the function that validates every incoming request.

File: backend/auth.py

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt, JWTError
import httpx

from config import AUDIENCE, JWKS_URI, ISSUER

security = HTTPBearer()

# Cache JWKS keys in memory
_jwks_cache: dict | None = None


async def _get_jwks() -> dict:
    """Fetch and cache the JSON Web Key Set from Microsoft."""
    global _jwks_cache
    if _jwks_cache is None:
        async with httpx.AsyncClient() as client:
            resp = await client.get(JWKS_URI)
            resp.raise_for_status()
            _jwks_cache = resp.json()
    return _jwks_cache


def _find_rsa_key(jwks: dict, kid: str) -> dict | None:
    """Find the signing key matching the token's kid header."""
    for key in jwks.get("keys", []):
        if key["kid"] == kid:
            return {
                "kty": key["kty"],
                "kid": key["kid"],
                "use": key["use"],
                "n": key["n"],
                "e": key["e"],
            }
    return None


async def validate_token(
    credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
    """Validate the Bearer token and return the decoded claims."""
    token = credentials.credentials

    try:
        unverified_header = jwt.get_unverified_header(token)
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token header",
        )

    jwks = await _get_jwks()
    rsa_key = _find_rsa_key(jwks, unverified_header.get("kid", ""))

    if rsa_key is None:
        # Key not found — clear cache and retry once (key rotation)
        global _jwks_cache
        _jwks_cache = None
        jwks = await _get_jwks()
        rsa_key = _find_rsa_key(jwks, unverified_header.get("kid", ""))

    if rsa_key is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Unable to find signing key",
        )

    try:
        claims = jwt.decode(
            token,
            rsa_key,
            algorithms=["RS256"],
            audience=AUDIENCE,
            issuer=ISSUER,
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token has expired",
        )
    except JWTError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Token validation failed: {str(e)}",
        )

    return claims

The validation flow step by step:

Incoming request with Authorization: Bearer <token>
        |
        v
1. Extract token from header (HTTPBearer)
        |
        v
2. Read unverified header to get 'kid' (key ID)
        |
        v
3. Fetch JWKS from Microsoft (cached in memory)
        |
        v
4. Find the RSA key matching the kid
   |                          |
   v                          v
   Found                   Not found
   |                          |
   v                          v
5. jwt.decode()         Clear cache, retry
   - RS256 algorithm    (handles key rotation)
   - Check audience
   - Check issuer
   - Check expiry
        |
        v
6. Return decoded claims (oid, name, email, scp, etc.)

Why JWKS caching matters: Microsoft's JWKS endpoint returns 3-4 signing keys. We cache them in memory after the first fetch. If a token arrives with an unknown kid (key ID), we clear the cache and re-fetch — this handles key rotation gracefully without downtime.

Protected Endpoints#

File: backend/main.py

from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware

from auth import validate_token

app = FastAPI(title="Blog 2 — Protected API")

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

# In-memory task store (keyed by user ID)
tasks_db: dict[str, list[dict]] = {}


@app.get("/api/me")
async def get_me(claims: dict = Depends(validate_token)):
    """Return the authenticated user's profile from token claims."""
    return {
        "id": claims.get("oid", ""),
        "name": claims.get("name", ""),
        "email": claims.get("preferred_username", ""),
        "tenant_id": claims.get("tid", ""),
        "scopes": claims.get("scp", ""),
    }


@app.get("/api/tasks")
async def get_tasks(claims: dict = Depends(validate_token)):
    """Get all tasks for the authenticated user."""
    user_id = claims.get("oid", "")
    return tasks_db.get(user_id, [])


@app.post("/api/tasks")
async def create_task(task: dict, claims: dict = Depends(validate_token)):
    """Create a new task for the authenticated user."""
    user_id = claims.get("oid", "")
    if user_id not in tasks_db:
        tasks_db[user_id] = []

    new_task = {
        "id": len(tasks_db[user_id]) + 1,
        "title": task.get("title", ""),
        "completed": False,
    }
    tasks_db[user_id].append(new_task)
    return new_task


@app.patch("/api/tasks/{task_id}")
async def update_task(
    task_id: int, updates: dict, claims: dict = Depends(validate_token)
):
    """Toggle or update a task for the authenticated user."""
    user_id = claims.get("oid", "")
    user_tasks = tasks_db.get(user_id, [])

    for task in user_tasks:
        if task["id"] == task_id:
            if "completed" in updates:
                task["completed"] = updates["completed"]
            if "title" in updates:
                task["title"] = updates["title"]
            return task

    return {"error": "Task not found"}


@app.delete("/api/tasks/{task_id}")
async def delete_task(task_id: int, claims: dict = Depends(validate_token)):
    """Delete a task for the authenticated user."""
    user_id = claims.get("oid", "")
    user_tasks = tasks_db.get(user_id, [])
    tasks_db[user_id] = [t for t in user_tasks if t["id"] != task_id]
    return {"status": "deleted"}

Every endpoint uses Depends(validate_token) — FastAPI's dependency injection runs the JWT validation before the endpoint function executes. If validation fails, the endpoint never runs and the user gets a 401.

Per-user data isolation: Tasks are keyed by the oid claim (the user's Object ID in Entra ID). User A can never see or modify User B's tasks — the token determines identity, and the backend enforces isolation.

Backend Dependencies#

fastapi==0.115.0
uvicorn[standard]==0.30.6
python-jose[cryptography]==3.3.0
httpx==0.27.2
python-dotenv==1.0.1
PackagePurpose
fastapiWeb framework with dependency injection
uvicornASGI server
python-jose[cryptography]JWT decoding and RS256 signature verification
httpxAsync HTTP client for fetching JWKS
python-dotenvLoad .env files

Step 4: Frontend Changes#

The frontend structure is mostly identical to Blog 1. Three things change:

4a. Auth Config — New API Scope#

File: frontend/src/config/auth-config.ts

import { Configuration, LogLevel } from "@azure/msal-browser";

export const msalConfig: Configuration = {
  auth: {
    clientId: process.env.NEXT_PUBLIC_AZURE_CLIENT_ID || "",
    authority: `https://login.microsoftonline.com/${
      process.env.NEXT_PUBLIC_AZURE_TENANT_ID || "common"
    }`,
    redirectUri: "/",
    postLogoutRedirectUri: "/",
    navigateToLoginRequestUrl: false,
  },
  cache: {
    cacheLocation: "sessionStorage",
    storeAuthStateInCookie: false,
  },
  // ... logger config same as Blog 1
};

// NEW: Scopes for our own protected API
export const apiLoginRequest = {
  scopes: [process.env.NEXT_PUBLIC_API_SCOPE || ""],
};

// Scopes for Microsoft Graph (profile photo)
export const graphLoginRequest = {
  scopes: ["User.Read"],
};

// NEW: API base URL
export const apiConfig = {
  baseUrl: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
};

The key change: instead of a single loginRequest with User.Read, we now have two scope sets — one for our API (api://{id}/access_as_user) and one for Graph. The frontend requests different tokens depending on which API it's calling.

4b. API Client — Typed HTTP Client#

File: frontend/src/lib/api.ts

import { apiConfig } from "@/config/auth-config";

const BASE = apiConfig.baseUrl;

async function authFetch(path: string, token: string, options?: RequestInit) {
  const res = await fetch(`${BASE}${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      ...options?.headers,
    },
  });

  if (!res.ok) {
    const detail = await res.text();
    throw new Error(`API error ${res.status}: ${detail}`);
  }

  return res.json();
}

export interface UserProfile {
  id: string;
  name: string;
  email: string;
  tenant_id: string;
  scopes: string;
}

export interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export async function getMe(token: string): Promise<UserProfile> {
  return authFetch("/api/me", token);
}

export async function getTasks(token: string): Promise<Task[]> {
  return authFetch("/api/tasks", token);
}

export async function createTask(
  token: string,
  title: string
): Promise<Task> {
  return authFetch("/api/tasks", token, {
    method: "POST",
    body: JSON.stringify({ title }),
  });
}

export async function updateTask(
  token: string,
  taskId: number,
  updates: Partial<Task>
): Promise<Task> {
  return authFetch(`/api/tasks/${taskId}`, token, {
    method: "PATCH",
    body: JSON.stringify(updates),
  });
}

export async function deleteTask(
  token: string,
  taskId: number
): Promise<void> {
  return authFetch(`/api/tasks/${taskId}`, token, {
    method: "DELETE",
  });
}

The authFetch wrapper attaches the Bearer token to every request. Each exported function maps to a backend endpoint with proper TypeScript types.

4c. The useApi Hook — Token + CRUD Operations#

File: frontend/src/hooks/use-api.ts

"use client";

import { useState, useEffect, useCallback } from "react";
import { useMsal } from "@azure/msal-react";
import { InteractionRequiredAuthError } from "@azure/msal-browser";
import { apiLoginRequest } from "@/config/auth-config";
import {
  getMe, getTasks, createTask, updateTask, deleteTask,
  UserProfile, Task,
} from "@/lib/api";

export function useApi() {
  const { instance, accounts } = useMsal();
  const [profile, setProfile] = useState<UserProfile | null>(null);
  const [tasks, setTasks] = useState<Task[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // Reusable token acquisition
  const getToken = useCallback(async (): Promise<string> => {
    try {
      const response = await instance.acquireTokenSilent({
        ...apiLoginRequest,
        account: accounts[0],
      });
      return response.accessToken;
    } catch (silentError) {
      if (silentError instanceof InteractionRequiredAuthError) {
        const response =
          await instance.acquireTokenPopup(apiLoginRequest);
        return response.accessToken;
      }
      throw silentError;
    }
  }, [instance, accounts]);

  // Initial data fetch
  const fetchData = useCallback(async () => {
    if (accounts.length === 0) {
      setLoading(false);
      return;
    }
    setLoading(true);
    setError(null);
    try {
      const token = await getToken();
      const [profileData, tasksData] = await Promise.all([
        getMe(token),
        getTasks(token),
      ]);
      setProfile(profileData);
      setTasks(tasksData);
    } catch (err) {
      setError(
        err instanceof Error ? err.message : "Failed to fetch data"
      );
    } finally {
      setLoading(false);
    }
  }, [accounts, getToken]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  // CRUD operations
  const addTask = useCallback(async (title: string) => {
    const token = await getToken();
    const task = await createTask(token, title);
    setTasks((prev) => [...prev, task]);
  }, [getToken]);

  const toggleTask = useCallback(async (taskId: number, completed: boolean) => {
    const token = await getToken();
    const updated = await updateTask(token, taskId, { completed });
    setTasks((prev) => prev.map((t) => (t.id === taskId ? updated : t)));
  }, [getToken]);

  const removeTask = useCallback(async (taskId: number) => {
    const token = await getToken();
    await deleteTask(token, taskId);
    setTasks((prev) => prev.filter((t) => t.id !== taskId));
  }, [getToken]);

  return {
    profile, tasks, loading, error,
    refetch: fetchData, addTask, toggleTask, removeTask,
  };
}

Compared to Blog 1's useGraph hook:

Blog 1: useGraphBlog 2: useApi
Requests User.Read scopeRequests api://{id}/access_as_user scope
Calls Microsoft Graph APICalls our FastAPI backend
Read-only (profile + photo)Full CRUD (profile + tasks)
Single fetchData functiongetToken extracted for reuse by CRUD operations

The getToken function is extracted as a reusable callback because every CRUD operation needs a fresh token. The same silent-first-then-popup pattern from Blog 1 applies.


Step 5: The Complete Token Flow#

Let's trace what happens when the frontend calls GET /api/tasks:

 Frontend                 MSAL              Entra ID         FastAPI Backend
    |                       |                   |                   |
    |-- acquireTokenSilent -|                   |                   |
    |   scope: api://{id}/  |                   |                   |
    |   access_as_user      |                   |                   |
    |                       |-- Check cache ----|                   |
    |                       |<- Cached token ---|                   |
    |<-- Access token ------|                   |                   |
    |                       |                   |                   |
    |-- GET /api/tasks -------------------------------------------->|
    |   Authorization:      |                   |                   |
    |   Bearer <token>      |                   |                   |
    |                       |                   |                   |
    |                       |                   |       Extract kid from header
    |                       |                   |       Fetch JWKS (cached)
    |                       |                   |       Find RSA key by kid
    |                       |                   |       Verify signature (RS256)
    |                       |                   |       Check aud == api://{id}
    |                       |                   |       Check iss == sts.windows.net
    |                       |                   |       Check exp > now
    |                       |                   |       Extract oid claim
    |                       |                   |                   |
    |<-- [task1, task2] -------------------------------------------|
    |   (only this user's tasks)                |                   |

The critical point: the frontend requests a token with the API's scope, not Graph's. Entra ID returns a token whose aud (audience) claim is api://{api-client-id}. The backend verifies this audience matches its own client ID — if someone tries to use a Graph token, it gets rejected.


Step 6: Understanding JWT Claims#

When the backend decodes a valid token, it gets claims like this:

{
  "aud": "api://9b28cc57-ebc1-48ae-ad72-cb603bf14200",
  "iss": "https://sts.windows.net/0f485f73-2bd0-4cb2-a8fc.../",
  "iat": 1740600000,
  "exp": 1740603600,
  "name": "Bui Minh Quan",
  "oid": "b1b0160f-5d3f-484e-beca-15f233b78356",
  "preferred_username": "quanbm2710@gmail.com",
  "scp": "access_as_user",
  "tid": "0f485f73-2bd0-4cb2-a8fc-6fa326c792fc",
  "ver": "1.0"
}
ClaimMeaningHow We Use It
audAudience — who the token is forValidated against api://{clientId}
issIssuer — who created the tokenValidated against sts.windows.net/{tenant}
expExpiry timestampAutomatically checked by jwt.decode()
oidUser's Object ID (globally unique)Key for per-user data isolation
nameDisplay nameShown in the profile card
preferred_usernameEmail addressShown in the profile card
scpScopes grantedShows access_as_user
tidTenant IDIdentifies the organization

Step 7: CORS Configuration#

The backend needs CORS middleware because the frontend (port 3000) and backend (port 8000) are different origins:

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

Without this, the browser blocks the frontend's fetch requests to the backend. In production, replace localhost:3000 with your actual frontend domain.


Running the App#

1. Register the Apps#

az login
./setup.sh

2. Start the Backend#

cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload

Backend runs at http://localhost:8000. You can verify at http://localhost:8000/docs (FastAPI auto-generates Swagger UI).

3. Start the Frontend#

cd frontend
npm install
npm run dev

Frontend runs at http://localhost:3000.

4. Test the Flow#

  1. Open http://localhost:3000
  2. Click "Sign in with Microsoft"
  3. After login, navigate to Dashboard
  4. You should see your profile (from token claims, not Graph)
  5. Add, complete, and delete tasks — all via the protected API

Cleanup#

./cleanup.sh

Common Pitfalls#

1. "audience is invalid" Error#

The token's aud claim doesn't match your backend's expected audience.

Fix: Ensure AZURE_API_CLIENT_ID in backend/.env matches the API app registration's client ID (not the SPA's). The audience should be api://{API_CLIENT_ID}.

2. "issuer is invalid" Error#

The iss claim format varies between v1 and v2 tokens. v1 tokens use https://sts.windows.net/{tenant}/ while v2 tokens use https://login.microsoftonline.com/{tenant}/v2.0.

Fix: Check which version your token uses (look at the ver claim). Our setup uses v1 tokens, so the issuer is https://sts.windows.net/{tenant}/.

3. CORS Errors#

The browser blocks requests if the backend doesn't explicitly allow the frontend's origin.

Fix: Verify allow_origins in the CORS middleware includes http://localhost:3000 (exact match, no trailing slash).

4. "interaction_required" When Requesting API Scope#

The user hasn't consented to the API scope yet. This happens on first use.

Fix: MSAL's acquireTokenPopup fallback handles this automatically — the user will see a consent dialog asking to allow "Access API as user".

5. Token Has Wrong Scope#

If you request User.Read instead of your custom API scope, the token will be for Graph, not your API.

Fix: Make sure NEXT_PUBLIC_API_SCOPE in frontend/.env.local is set to api://{API_CLIENT_ID}/access_as_user.


What's Next#

In Blog 3: Role-Based Access Control (RBAC), we'll:

  • Define App Roles in Azure (Admin, Editor, Viewer)
  • Assign roles to users in the Azure Portal
  • Check roles in both the backend (authorization middleware) and frontend (conditional UI)
  • Build an admin panel that only Admin-role users can access

The JWT validation we built here is the foundation — RBAC adds a roles claim to the token that the backend checks before allowing access.


Conclusion#

You've built a complete frontend-to-backend authentication flow with Microsoft Entra ID:

  • Two app registrations — separate identities for frontend and API
  • Custom API scopeaccess_as_user scoped specifically to your backend
  • JWT validation with JWKS — stateless, fast, handles key rotation
  • Per-user data isolation — using the oid claim as the user key
  • CRUD operations — every endpoint protected by Depends(validate_token)
  • Token flow — silent acquisition with popup fallback for both Graph and API scopes

The backend is intentionally simple (in-memory storage, no database) to keep the focus on authentication. In a real app, you'd swap tasks_db for a database — but the auth layer stays exactly the same.


Resources#

Happy building!