
MSAL React Authentication: From Setup to Backend Integration
Master Microsoft authentication in React SPAs with MSAL. Learn when to use it, how to configure it, and three proven patterns for backend API integration.
MSAL React Authentication: From Setup to Backend Integration
Your React app needs enterprise authentication. Users expect single sign-on with their corporate Microsoft accounts. The security team demands proper token handling. And somehow, your backend API needs to trust that the person clicking buttons is actually who they claim to be.
This is where MSAL (Microsoft Authentication Library) becomes your best friend — or your worst nightmare, depending on how you approach it.
In this guide, we'll cut through the complexity. You'll learn when MSAL React is the right choice, how to set it up properly, and — most importantly — three battle-tested patterns for integrating authentication with your backend API.
When Should You Use MSAL React?
MSAL React is the right choice when:
- Your users authenticate with Microsoft Entra ID (formerly Azure AD)
- You're building a Single-Page Application (SPA) without a backend-for-frontend
- You need access to Microsoft Graph or other Azure-protected APIs
- Enterprise SSO is a requirement — users sign in once across multiple apps
- You need fine-grained control over token acquisition and caching
Consider alternatives when:
- You have a dedicated backend that can handle OAuth flows (use server-side auth instead)
- You're building a mobile app (use
@azure/msal-react-nativeor native SDKs) - Your identity provider isn't Microsoft-based (use Auth0, Clerk, or generic OIDC libraries)
- You need server-side rendering with authentication (AuthJS or similar)
Key insight: MSAL React uses the OAuth 2.0 Authorization Code Flow with PKCE, which is the most secure option for public clients like SPAs. No client secrets are exposed in the browser.
Architecture Overview
Before diving into code, understand the authentication flow:
MSAL authentication flow diagram showing the complete sequence from user sign-in through token acquisition to backend API calls
- User clicks "Sign In" — MSAL redirects to Microsoft login page
- User authenticates (password, MFA, SSO)
- Microsoft redirects back with authorization code
- MSAL exchanges code for tokens (ID + Access)
- Tokens cached in browser (sessionStorage/localStorage)
- App calls backend API with access token
- Backend validates token and processes request
The critical question is step 7: How does your backend validate the token? We'll explore three patterns later.
Setting Up MSAL React
Step 1: Register Your Application
Before writing any code, register your app in the Microsoft Entra admin center:
-
Navigate to Identity → Applications → App registrations
-
Click New registration
-
Configure:
- Name: Your app name
- Supported account types: Choose based on your needs
- Redirect URI: Select "Single-page application" and add
http://localhost:5173(Vite default)
-
Note your Application (client) ID and Directory (tenant) ID
-
Under API permissions, add any Microsoft Graph permissions your app needs
Production tip: Register separate apps for development and production. Use different redirect URIs for each environment.
Step 2: Install Dependencies
npm install @azure/msal-react @azure/msal-browser
The @azure/msal-react package is a wrapper around @azure/msal-browser, providing React-specific hooks and components.
Step 3: Configure MSAL
Create a configuration file that defines your authentication settings:
// src/lib/auth/msal-config.ts
import { Configuration, LogLevel } from '@azure/msal-browser';
export const msalConfig: Configuration = {
auth: {
clientId: import.meta.env.VITE_AZURE_CLIENT_ID,
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AZURE_TENANT_ID}`,
redirectUri: import.meta.env.VITE_REDIRECT_URI || window.location.origin,
postLogoutRedirectUri: window.location.origin,
navigateToLoginRequestUrl: true,
},
cache: {
// sessionStorage is more secure (cleared on tab close)
// localStorage persists across sessions
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false, // Set true for IE11 support
},
system: {
loggerOptions: {
logLevel: import.meta.env.DEV ? LogLevel.Verbose : LogLevel.Error,
piiLoggingEnabled: false,
loggerCallback: (level, message, containsPii) => {
if (containsPii) return;
switch (level) {
case LogLevel.Error:
console.error(message);
break;
case LogLevel.Warning:
console.warn(message);
break;
case LogLevel.Info:
console.info(message);
break;
case LogLevel.Verbose:
console.debug(message);
break;
}
},
},
},
};
// Scopes for ID token (user profile info)
export const loginRequest = {
scopes: ['openid', 'profile', 'email'],
};
// Scopes for your backend API
export const apiRequest = {
scopes: [`api://${import.meta.env.VITE_AZURE_CLIENT_ID}/access_as_user`],
};
// Scopes for Microsoft Graph (if needed)
export const graphRequest = {
scopes: ['User.Read'],
};
Security note: The
cacheLocationchoice matters. UsesessionStoragefor sensitive applications — tokens are cleared when the browser tab closes. UselocalStorageonly when persistent sessions are explicitly required.
Step 4: Initialize the MSAL Provider
Wrap your application with MsalProvider at the root level:
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { PublicClientApplication, EventType } from '@azure/msal-browser';
import { MsalProvider } from '@azure/msal-react';
import { msalConfig } from '@/lib/auth/msal-config';
import App from './App';
// Create MSAL instance ONCE and reuse throughout the app
const msalInstance = new PublicClientApplication(msalConfig);
// Handle redirect promise on page load
msalInstance.initialize().then(() => {
// Account selection logic for multi-account scenarios
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
msalInstance.setActiveAccount(accounts[0]);
}
// Listen for sign-in events
msalInstance.addEventCallback((event) => {
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
const account = (event.payload as { account: (typeof accounts)[0] }).account;
msalInstance.setActiveAccount(account);
}
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MsalProvider instance={msalInstance}>
<App />
</MsalProvider>
</React.StrictMode>,
);
});
Critical: Instantiate
PublicClientApplicationonly once per application. Multiple instances cause cache conflicts and race conditions. This is explicitly called out in the MSAL documentation.
Step 5: Create Authentication Components
Build reusable components for login/logout:
// src/components/auth/sign-in-button.tsx
import { useMsal } from '@azure/msal-react';
import { loginRequest } from '@/lib/auth/msal-config';
import { Button } from '@/components/ui/button';
export function SignInButton() {
const { instance } = useMsal();
const handleLogin = () => {
// Redirect flow (recommended for most cases)
instance.loginRedirect(loginRequest);
// Alternative: Popup flow (better UX but blocked by some browsers)
// instance.loginPopup(loginRequest);
};
return <Button onClick={handleLogin}>Sign in with Microsoft</Button>;
}
// src/components/auth/sign-out-button.tsx
import { useMsal } from '@azure/msal-react';
import { Button } from '@/components/ui/button';
export function SignOutButton() {
const { instance } = useMsal();
const handleLogout = () => {
instance.logoutRedirect({
postLogoutRedirectUri: window.location.origin,
});
};
return (
<Button variant='outline' onClick={handleLogout}>
Sign out
</Button>
);
}
Step 6: Protect Routes and Components
MSAL React provides template components for conditional rendering:
// src/components/auth/auth-guard.tsx
import { AuthenticatedTemplate, UnauthenticatedTemplate } from '@azure/msal-react';
import { SignInButton } from './sign-in-button';
interface AuthGuardProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function AuthGuard({ children, fallback }: AuthGuardProps) {
return (
<>
<AuthenticatedTemplate>{children}</AuthenticatedTemplate>
<UnauthenticatedTemplate>
{fallback || (
<div className='flex flex-col items-center justify-center min-h-screen gap-4'>
<h1 className='text-2xl font-bold'>Welcome</h1>
<p className='text-muted-foreground'>Please sign in to continue</p>
<SignInButton />
</div>
)}
</UnauthenticatedTemplate>
</>
);
}
For programmatic access to authentication state, use the hooks:
// src/hooks/use-auth.ts
import { useMsal, useIsAuthenticated } from '@azure/msal-react';
import { InteractionStatus } from '@azure/msal-browser';
export function useAuth() {
const { instance, accounts, inProgress } = useMsal();
const isAuthenticated = useIsAuthenticated();
const activeAccount = instance.getActiveAccount();
return {
isAuthenticated,
isLoading: inProgress !== InteractionStatus.None,
user: activeAccount
? {
id: activeAccount.localAccountId,
name: activeAccount.name,
email: activeAccount.username,
tenantId: activeAccount.tenantId,
}
: null,
accounts,
instance,
};
}
Backend Integration Strategies
Here's where most tutorials fall short. Getting a user signed in is the easy part. The real challenge is securely connecting your authenticated frontend to your backend API.
Three patterns emerge, each with distinct tradeoffs:
Pattern 1: Direct Token Validation (Stateless)
The frontend acquires an access token for your API and sends it with every request. The backend validates the JWT signature and claims without storing session state.
How it works:
- Frontend calls
acquireTokenSilentto get an access token (from cache or refreshed) - Frontend sends the API request with
Authorization: Bearer <token>header - Backend extracts the token and fetches Azure AD's public signing keys (JWKS) — cached after first request
- Backend verifies the JWT signature using the public keys
- Backend validates claims: audience (
aud), issuer (iss), expiration (exp) - If valid, request proceeds with user identity extracted from token claims
Performance note: The JWKS fetch happens once and is cached. Subsequent validations only perform cryptographic signature verification — typically sub-millisecond.
JWT validation flow showing token verification steps from signature check through claims validation
Frontend: Acquire and attach tokens
// src/lib/api/auth-fetch.ts
import { PublicClientApplication } from '@azure/msal-browser';
import { apiRequest } from '@/lib/auth/msal-config';
export async function authFetch(
msalInstance: PublicClientApplication,
url: string,
options: RequestInit = {},
): Promise<Response> {
const account = msalInstance.getActiveAccount();
if (!account) {
throw new Error('No active account. User must sign in.');
}
// Always use acquireTokenSilent first - it handles caching and refresh
const tokenResponse = await msalInstance.acquireTokenSilent({
...apiRequest,
account,
});
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${tokenResponse.accessToken}`,
'Content-Type': 'application/json',
},
});
}
Backend: Validate the JWT (Python/FastAPI example)
# src/infrastructure/auth/token_validator.py
import httpx
from jose import jwt, JWTError
from functools import lru_cache
from typing import Any
from src.infrastructure.config.settings import Settings
class TokenValidationError(Exception):
"""Raised when token validation fails."""
pass
class AzureADTokenValidator:
"""Validates Azure AD JWT access tokens."""
def __init__(self, settings: Settings):
self._tenant_id = settings.azure_ad_tenant_id
self._client_id = settings.azure_ad_client_id
self._issuer = f"https://login.microsoftonline.com/{self._tenant_id}/v2.0"
self._jwks_uri = (
f"https://login.microsoftonline.com/{self._tenant_id}/discovery/v2.0/keys"
)
self._jwks_client = httpx.Client()
@lru_cache(maxsize=1)
def _get_signing_keys(self) -> dict[str, Any]:
"""Fetch and cache JWKS from Azure AD."""
response = self._jwks_client.get(self._jwks_uri)
response.raise_for_status()
return response.json()
def validate_token(self, token: str) -> dict[str, Any]:
"""
Validate an Azure AD access token.
Returns the decoded token claims if valid.
Raises TokenValidationError if invalid.
"""
try:
# Get the signing keys
jwks = self._get_signing_keys()
# Decode and validate the token
claims = jwt.decode(
token,
jwks,
algorithms=["RS256"],
audience=self._client_id,
issuer=self._issuer,
options={
"verify_aud": True,
"verify_iss": True,
"verify_exp": True,
"verify_nbf": True,
},
)
return claims
except JWTError as e:
raise TokenValidationError(f"Token validation failed: {e}") from e
FastAPI dependency for protected routes:
# src/presentation/api/dependencies/auth.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from src.infrastructure.auth.token_validator import (
AzureADTokenValidator,
TokenValidationError,
)
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
validator: AzureADTokenValidator = Depends(),
) -> dict:
"""Extract and validate the current user from the access token."""
try:
claims = validator.validate_token(credentials.credentials)
return {
"id": claims.get("oid"), # Object ID (unique user identifier)
"email": claims.get("preferred_username"),
"name": claims.get("name"),
"tenant_id": claims.get("tid"),
"scopes": claims.get("scp", "").split(" "),
}
except TokenValidationError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)
Pros:
- Fully stateless — scales horizontally without session storage
- Standard OAuth 2.0 pattern — well-documented and widely understood
- No backend state to manage
Cons:
- Token validation on every request (mitigated by caching JWKS)
- Tokens are bearer tokens — if stolen, they grant access until expiry
- Requires careful scope management
Pattern 2: Token Exchange with Backend Sessions
The frontend sends the token once to establish a session. The backend validates it, creates a server-side session, and returns a session cookie. Subsequent requests use the cookie instead of the token.
Phase 1: Session Establishment
The user signs in via MSAL, and the frontend exchanges the Azure AD token for a server-side session. This happens once per login.
- Frontend acquires access token from MSAL (cached or refreshed)
- Frontend sends
POST /auth/loginwith the token in the Authorization header - Backend validates the JWT against Azure AD's signing keys
- Backend creates a session in Redis/database with user claims
- Backend returns a secure, httpOnly session cookie
- Frontend stores nothing — the browser handles the cookie automatically
Phase 2: Authenticated Requests
All subsequent API calls use the session cookie. No token management required on the frontend.
- Frontend calls
GET /api/data— browser automatically includes the cookie - Backend looks up session in the store, validates expiration
- Backend extracts user context from session data
- Request proceeds with full user identity
Frontend: Exchange token for session
// src/lib/auth/session-auth.ts
import { PublicClientApplication } from '@azure/msal-browser';
import { apiRequest } from '@/lib/auth/msal-config';
export async function establishSession(msalInstance: PublicClientApplication): Promise<void> {
const account = msalInstance.getActiveAccount();
if (!account) {
throw new Error('No active account');
}
const tokenResponse = await msalInstance.acquireTokenSilent({
...apiRequest,
account,
});
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${tokenResponse.accessToken}`,
},
credentials: 'include', // Important: include cookies
});
if (!response.ok) {
throw new Error('Session establishment failed');
}
}
// Regular API calls use cookies automatically
export async function apiFetch(url: string, options: RequestInit = {}): Promise<Response> {
return fetch(url, {
...options,
credentials: 'include',
headers: {
...options.headers,
'Content-Type': 'application/json',
},
});
}
Backend: Create and manage sessions
# src/presentation/api/routes/auth.py
from fastapi import APIRouter, Depends, Response, HTTPException
from uuid import uuid4
from src.infrastructure.auth.token_validator import AzureADTokenValidator
from src.infrastructure.session.session_store import SessionStore
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
async def login(
response: Response,
credentials: HTTPAuthorizationCredentials = Depends(security),
validator: AzureADTokenValidator = Depends(),
session_store: SessionStore = Depends(),
):
"""Exchange Azure AD token for a session cookie."""
claims = validator.validate_token(credentials.credentials)
# Create session with user data
session_id = str(uuid4())
session_data = {
"user_id": claims.get("oid"),
"email": claims.get("preferred_username"),
"name": claims.get("name"),
"tenant_id": claims.get("tid"),
}
await session_store.create(session_id, session_data, ttl=3600)
# Set secure, httpOnly cookie
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
secure=True, # HTTPS only
samesite="lax",
max_age=3600,
)
return {"message": "Session established"}
@router.post("/logout")
async def logout(
response: Response,
session_id: str = Cookie(None),
session_store: SessionStore = Depends(),
):
"""Invalidate the session."""
if session_id:
await session_store.delete(session_id)
response.delete_cookie("session_id")
return {"message": "Logged out"}
Pros:
- Session can be revoked server-side (immediate logout)
- Cookies are automatically sent — simpler frontend code
- Can store additional session data not in the token
Cons:
- Requires session storage (Redis, database, etc.)
- Not purely stateless — horizontal scaling needs shared session store
- Cookie handling complexity (CORS, SameSite, secure flags)
Pattern 3: On-Behalf-Of Flow (Downstream APIs)
When your backend needs to call other Microsoft APIs (Graph, Azure services) on behalf of the user, use the On-Behalf-Of (OBO) flow. The backend exchanges the user's token for a new token with different scopes.
How it works:
- Frontend acquires a token scoped to your backend API and sends the request
- Backend receives the user's token and needs to call Microsoft Graph (or another API)
- Backend sends a token exchange request to Azure AD's token endpoint, including:
- The user's original token (as
assertion) - The backend's client credentials (client ID + secret)
- The desired scopes for the downstream API (e.g.,
User.Readfor Graph)
- The user's original token (as
- Azure AD validates the assertion and issues a new token scoped for Graph
- Backend calls Microsoft Graph with the new token — Graph sees this as the user making the request
- Backend returns the combined result to the frontend
Key insight: The user never directly authenticates to Graph. Their identity "flows through" your backend via the OBO exchange. This maintains the principle of least privilege — the frontend token only grants access to your API, not to Graph directly.
Backend: Exchange tokens with OBO
# src/infrastructure/auth/obo_client.py
import httpx
from typing import Any
from src.infrastructure.config.settings import Settings
class OnBehalfOfClient:
"""Exchange user tokens for downstream API access."""
def __init__(self, settings: Settings):
self._tenant_id = settings.azure_ad_tenant_id
self._client_id = settings.azure_ad_client_id
self._client_secret = settings.azure_ad_client_secret
self._token_endpoint = (
f"https://login.microsoftonline.com/{self._tenant_id}/oauth2/v2.0/token"
)
async def exchange_token(
self,
user_token: str,
scopes: list[str],
) -> dict[str, Any]:
"""
Exchange a user's access token for a new token with different scopes.
This implements the OAuth 2.0 On-Behalf-Of flow.
"""
async with httpx.AsyncClient() as client:
response = await client.post(
self._token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"client_id": self._client_id,
"client_secret": self._client_secret,
"assertion": user_token,
"scope": " ".join(scopes),
"requested_token_use": "on_behalf_of",
},
)
if response.status_code != 200:
raise Exception(f"Token exchange failed: {response.text}")
return response.json()
# Usage in a service
class UserProfileService:
"""Fetch user profile from Microsoft Graph on behalf of the user."""
def __init__(self, obo_client: OnBehalfOfClient):
self._obo = obo_client
async def get_user_photo(self, user_token: str) -> bytes:
"""Get user's profile photo from Graph API."""
# Exchange for Graph token
token_response = await self._obo.exchange_token(
user_token,
scopes=["https://graph.microsoft.com/User.Read"],
)
graph_token = token_response["access_token"]
# Call Graph API
async with httpx.AsyncClient() as client:
response = await client.get(
"https://graph.microsoft.com/v1.0/me/photo/$value",
headers={"Authorization": f"Bearer {graph_token}"},
)
return response.content
Important: OBO requires your backend to be registered as a confidential client with a client secret or certificate. The frontend SPA remains a public client.
Pros:
- Access downstream Microsoft APIs with user context
- User permissions are respected (delegated, not application permissions)
- Single sign-on experience — user authenticates once
Cons:
- Requires client secret on the backend (secure storage needed)
- More complex token management
- Consent must be configured correctly in app registration
Handling Token Refresh
MSAL handles token refresh automatically when you use acquireTokenSilent. However, you need to handle the case where silent acquisition fails:
// src/hooks/use-api-token.ts
import { useMsal } from '@azure/msal-react';
import { InteractionRequiredAuthError } from '@azure/msal-browser';
import { apiRequest } from '@/lib/auth/msal-config';
export function useApiToken() {
const { instance, accounts } = useMsal();
const getToken = async (): Promise<string> => {
const account = accounts[0];
if (!account) {
throw new Error('No account available');
}
try {
// Try silent acquisition first (uses cached/refreshed token)
const response = await instance.acquireTokenSilent({
...apiRequest,
account,
});
return response.accessToken;
} catch (error) {
// If silent fails, fall back to interactive
if (error instanceof InteractionRequiredAuthError) {
const response = await instance.acquireTokenPopup(apiRequest);
return response.accessToken;
}
throw error;
}
};
return { getToken };
}
Best practice: Always call
acquireTokenSilentfirst. It checks the cache, validates expiration, and automatically refreshes if needed. Only fall back to interactive methods when required.
Common Pitfalls and Solutions
1. CORS Issues with Token Requests
MSAL makes requests to Microsoft endpoints, which handle CORS correctly. But your backend must also be configured:
# FastAPI CORS configuration
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"], # Your frontend origin
allow_credentials=True, # Required for cookies
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
2. Token Audience Mismatch
The aud claim in your token must match what your backend expects:
# Common mistake: checking against the wrong audience
# Token has aud: "api://your-client-id/access_as_user"
# But you're checking against: "your-client-id"
# Solution: Use the full API URI as audience
EXPECTED_AUDIENCE = f"api://{settings.azure_ad_client_id}"
3. Multi-Account Handling
When users have multiple Microsoft accounts, MSAL might not know which to use:
// Always set the active account explicitly
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 1) {
// Let user choose or use business logic
const workAccount = accounts.find((acc) => acc.tenantId === expectedTenantId);
msalInstance.setActiveAccount(workAccount || accounts[0]);
} else if (accounts.length === 1) {
msalInstance.setActiveAccount(accounts[0]);
}
4. Popup Blockers
Some browsers block popups by default. Always provide a redirect fallback:
const handleLogin = async () => {
try {
await instance.loginPopup(loginRequest);
} catch (error) {
if (error instanceof BrowserAuthError && error.errorCode === 'popup_window_error') {
// Fallback to redirect
await instance.loginRedirect(loginRequest);
}
throw error;
}
};
Key Takeaways
- Use MSAL React for Microsoft identity — it handles OAuth 2.0 with PKCE, token caching, and refresh automatically
- Choose your backend pattern wisely — stateless JWT validation for scalability, sessions for revocability, OBO for downstream APIs
- Always use
acquireTokenSilentfirst — it handles caching and refresh; only fall back to interactive when required - Validate tokens properly — fetch JWKS from Microsoft, verify signature, audience, issuer, and expiration
- Configure CORS and cookies correctly — most authentication issues stem from misconfigured CORS or cookie settings
For an alternative approach using AuthJS with Google OAuth in Next.js applications, check out our guide on AuthJS v5 with Google OAuth: Production-Ready Setup.