Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save andrewjroberts/4b9ecec6f0754da588a5a11d88554594 to your computer and use it in GitHub Desktop.

Select an option

Save andrewjroberts/4b9ecec6f0754da588a5a11d88554594 to your computer and use it in GitHub Desktop.
MSAL + React Router Route Modules Auth Architecture
# MSAL + React Router Route Modules Auth Architecture
## 1. Decision
Use **`@azure/msal-browser` as the actual auth engine** and wrap it in a small app-owned auth facade/store.
Do **not** make `@azure/msal-react` the center of the architecture. It is useful for component-only convenience APIs, but your app needs both React hooks and point-in-time `.getState()` semantics from `clientLoader`s, which are outside React component context. MSAL React is built on React context, requires `MsalProvider`, and exposes hooks/templates to components under that provider. ([Microsoft Learn][1])
Recommended shape:
```txt
@azure/msal-browser
app auth facade/store
React hooks + clientLoaders + services + authorizedFetch
```
Optional later:
```txt
@azure/msal-react
```
Only add it if you specifically want `MsalProvider`, `useMsal`, `useIsAuthenticated`, `AuthenticatedTemplate`, or `MsalAuthenticationTemplate` in components. MSAL React is a wrapper around MSAL Browser, and Microsoft’s migration docs show `PublicClientApplication` from `@azure/msal-browser` being passed into `MsalProvider`. ([Microsoft Learn][2])
---
## 2. Why this architecture fits React Router route modules
React Router route modules own framework features like data loading, actions, revalidation, and error boundaries. ([React Router][3]) A `clientLoader` runs outside React component render, so it cannot directly use React context hooks like `useMsal()` or `useIsAuthenticated()`.
Your auth layer therefore needs two access modes:
```txt
Reactive component access:
useAuth()
useIsAuthenticated()
Point-in-time non-React access:
auth.getState()
auth.getAccessToken()
auth.start()
```
This matches your current auth-store pattern.
---
## 3. Library responsibility split
### `@azure/msal-browser`
This is the real SPA auth library.
Use it for:
```ts
new PublicClientApplication(config)
await msal.initialize()
await msal.handleRedirectPromise()
msal.loginRedirect(...)
msal.loginPopup(...)
msal.acquireTokenSilent(...)
msal.acquireTokenRedirect(...)
msal.acquireTokenPopup(...)
msal.logoutRedirect(...)
msal.logoutPopup(...)
msal.getAllAccounts()
msal.getActiveAccount()
msal.setActiveAccount(...)
msal.addEventCallback(...)
msal.removeEventCallback(...)
```
MSAL Browser requires initialization before calling APIs like login, token acquisition, or redirect handling. Microsoft documents that `initialize()` is async and must resolve before other MSAL APIs are invoked. ([Microsoft Learn][4])
### `@azure/msal-react`
This is a React adapter around the browser instance.
Use it only if you want:
```tsx
<MsalProvider instance={pca}>
useMsal()
useIsAuthenticated()
useAccount()
useMsalAuthentication()
<AuthenticatedTemplate />
<UnauthenticatedTemplate />
<MsalAuthenticationTemplate />
```
But for this app, it should not be the source of truth because loaders/services need auth access outside React context.
---
## 4. Recommended facade API
Create one app-owned auth module.
```ts
export const auth = {
start,
getState,
subscribe,
login,
logout,
getAccessToken,
setActiveAccount,
};
```
State shape:
```ts
export type AuthSnapshot = {
initialized: boolean;
inProgress: InteractionStatus;
accounts: AccountInfo[];
activeAccount: AccountInfo | null;
isAuthenticated: boolean;
lastAuthEventAt: number | null;
lastUserActivityAt: number | null;
idle: boolean;
interactionRequired: boolean;
error: Error | null;
};
```
React hooks should be derived from this same store:
```ts
export function useAuth(): AuthSnapshot {
return useSyncExternalStore(auth.subscribe, auth.getState, auth.getState);
}
export function useIsAuthenticated(): boolean {
return useAuth().isAuthenticated;
}
```
---
## 5. What `auth.start()` means
`auth.start()` is **not an MSAL API**. It is your wrapper’s idempotent bootstrap method.
It should mean:
```txt
Make the auth system safe to query.
```
Specifically:
```txt
initialize MSAL once
register MSAL event callbacks once
optionally process redirect response
restore/select active account from cache
publish the initial auth snapshot
```
Implementation pattern:
```ts
let startPromise: Promise<void> | undefined;
export function start() {
if (!startPromise) {
startPromise = startInternal();
}
return startPromise;
}
```
This makes it safe to call from every protected `clientLoader`. Multiple loaders may call `auth.start()`, but only one real startup sequence runs.
```ts
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
await requireAuth(request);
return mosaicService().getClientSummary("123");
}
```
---
## 6. Redirect processing
When using redirect login, the flow is:
```txt
1. User calls loginRedirect()
2. Browser leaves the app
3. Microsoft login happens
4. Browser returns to your configured redirectUri
5. MSAL must process the redirect response
6. MSAL caches account/token artifacts
7. App restores active account and continues
```
The processing step is:
```ts
const result = await msal.handleRedirectPromise();
```
`handleRedirectPromise()` returns an authentication result when a redirect response is detected; otherwise it returns `null`. ([azuread.github.io][5]) Microsoft also documents that redirect APIs require `handleRedirectPromise()` to run after returning from redirect so the token response is handled and temporary cache entries are cleaned up. ([Microsoft Learn][6])
There are two acceptable patterns.
### Pattern A: global/per-loader bootstrap
`auth.start()` calls `handleRedirectPromise()`.
```ts
async function startInternal() {
await msal.initialize();
const result = await msal.handleRedirectPromise();
if (result?.account) {
msal.setActiveAccount(result.account);
} else {
restoreActiveAccountFromCache();
}
registerEventsOnce();
publishAuthState();
}
```
Then every protected loader can safely do:
```ts
await requireAuth(request);
```
This is simple and robust. The downside is mostly architectural coupling: auth startup errors become global-ish, and every protected route waits on startup. It is usually not a performance concern.
### Pattern B: dedicated callback route
`auth.start()` only initializes and restores cache. `/auth/callback` owns redirect processing.
```ts
auth.start()
→ initialize MSAL
→ restore cached account
→ publish state
auth.completeRedirect()
→ initialize MSAL
→ handleRedirectPromise()
→ set active account
→ publish state
→ navigate to returnTo
```
This is cleaner if you want strict route ownership.
For your route-module app, either is acceptable. The simplest practical recommendation is:
```txt
Call requireAuth() from protected clientLoaders.
requireAuth() calls auth.start().
auth.start() is idempotent and shared-promise backed.
```
---
## 7. `requireAuth()` responsibility
`requireAuth()` should answer one question:
```txt
Does the app currently have an active signed-in account?
```
It should not generally acquire API tokens.
```ts
export async function requireAuth(request: Request) {
await auth.start();
const state = auth.getState();
if (!state.activeAccount) {
const url = new URL(request.url);
const returnTo = `${url.pathname}${url.search}`;
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
}
return state.activeAccount;
}
```
Why not acquire tokens here? Because access tokens are resource/scope-specific.
```txt
Mosaic token?
Graph token?
CRM token?
Read scope?
Write scope?
Different authority?
Different account?
```
A generic route guard should not know those details.
---
## 8. `acquireTokenSilent()` responsibility
Use `acquireTokenSilent()` **right before calling a protected resource**.
Microsoft’s guidance is explicit: every time the app needs an access token, call `acquireTokenSilent`; if it fails, use an interactive API. MSAL will look for a valid token in cache and try to refresh it with the cached refresh token if needed. ([Microsoft Learn][7]) MSAL also documents that it caches tokens and manages token lifetimes/refreshing through `acquireTokenSilent()` for a given account. ([Microsoft Learn][8])
App flow:
```txt
clientLoader
→ requireAuth()
→ check active account
→ mosaicService().getSomething()
→ authorizedFetch()
→ auth.getAccessToken()
→ msal.acquireTokenSilent()
```
Implementation:
```ts
export async function getAccessToken(scopes: string[]): Promise<string> {
await auth.start();
const { activeAccount } = auth.getState();
if (!activeAccount) {
throw new LoginRequiredError();
}
try {
const result = await msal.acquireTokenSilent({
account: activeAccount,
scopes,
});
return result.accessToken;
} catch (error) {
if (error instanceof InteractionRequiredAuthError) {
throw new ReauthenticationRequiredError();
}
throw error;
}
}
```
---
## 9. Silent refresh vs login route
The login route is **not** where silent refresh normally happens.
Silent refresh happens here:
```ts
msal.acquireTokenSilent(...)
```
That should usually be inside:
```txt
authorizedFetch()
auth.getAccessToken()
service request middleware
```
If silent refresh succeeds:
```txt
user enters protected route
service calls acquireTokenSilent
MSAL refreshes/returns token silently
API request succeeds
user never sees /login
```
If silent refresh fails:
```txt
acquireTokenSilent throws InteractionRequiredAuthError
app routes user to /login or starts acquireTokenRedirect()
user performs required interaction
app returns to original route
```
MSAL token lifetime docs note that silent acquisition is best-effort; there is never a guarantee that a token can be acquired silently. ([Microsoft Learn][9])
---
## 10. Login route responsibility
`/login` is the **interactive auth boundary**.
It decides whether to:
```txt
show a sign-in button
immediately call loginRedirect()
call acquireTokenRedirect() for reauth/consent
show “session expired”
preserve returnTo
```
Example:
```tsx
export default function Login() {
async function continueAuth() {
const state = auth.getState();
if (state.activeAccount) {
await msal.acquireTokenRedirect({
account: state.activeAccount,
scopes: ["api://mosaic/client.read"],
});
} else {
await msal.loginRedirect({
scopes: ["openid", "profile", "api://mosaic/client.read"],
});
}
}
return <button onClick={continueAuth}>Continue</button>;
}
```
Use `loginRedirect()` for first login. Use `acquireTokenRedirect()` when the user has an account but the resource token requires interaction.
---
## 11. Service creation pattern
This is fine:
```ts
export const mosaicService = () =>
new MosaicService({
baseUrl: "/api/mosaic",
fetch: authorizedFetch,
});
```
Creating a lightweight service wrapper per `clientLoader` is not a meaningful performance issue unless the constructor does heavy work.
The important rule:
```txt
Do not capture a bearer token when constructing the service.
```
Avoid this:
```ts
const token = auth.getState().accessToken;
export const mosaicService = () =>
new MosaicService({
token,
});
```
Prefer this:
```ts
export const mosaicService = () =>
new MosaicService({
baseUrl: "/api/mosaic",
getAccessToken: () => auth.getAccessToken(["api://mosaic/client.read"]),
});
```
Or better, inject a centralized fetcher:
```ts
export const mosaicService = () =>
new MosaicService({
baseUrl: "/api/mosaic",
fetch: authorizedFetch,
});
```
---
## 12. Request middleware / `authorizedFetch`
Yes, add a central request boundary that attaches tokens before sending.
```ts
export async function authorizedFetch(
input: RequestInfo | URL,
init: RequestInit = {},
) {
await auth.start();
const token = await auth.getAccessToken(["api://mosaic/client.read"]);
const response = await fetch(input, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
},
});
if (response.status === 401) {
throw new ReauthenticationRequiredError();
}
if (response.status === 403) {
throw new ForbiddenError();
}
return response;
}
```
This middleware should:
```txt
get fresh-enough token per request
attach Authorization header
handle 401/403 centrally
surface InteractionRequiredAuthError/ReauthenticationRequiredError
```
It should **not** try to be the real security boundary. The backend must validate the bearer token on every request.
---
## 13. Route guard vs request middleware
Use both, but for different jobs.
```txt
requireAuth()
route-level UX guard
checks active account
redirects unauthenticated users to /login
authorizedFetch()
API-boundary token middleware
gets access token for the specific API scope
attaches Authorization header
handles token rejection
```
Recommended loader:
```ts
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
await requireAuth(request);
try {
return await mosaicService().getClientSummary("123");
} catch (error) {
if (isInteractionRequired(error)) {
const url = new URL(request.url);
const returnTo = `${url.pathname}${url.search}`;
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
}
throw error;
}
}
```
---
## 14. Optional resource-specific guard
Generic `requireAuth()` should not acquire tokens.
But a resource-specific guard is reasonable:
```ts
export async function requireMosaicAccess(request: Request) {
await requireAuth(request);
try {
await auth.getAccessToken(["api://mosaic/client.read"]);
} catch (error) {
if (isInteractionRequired(error)) {
const url = new URL(request.url);
const returnTo = `${url.pathname}${url.search}`;
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
}
throw error;
}
}
```
Use this when a route is useless unless Mosaic access is available.
---
## 15. Activity monitoring
MSAL provides **auth event monitoring**, not full app/user activity monitoring.
Use MSAL events for auth state changes:
```ts
msal.addEventCallback((message) => {
// LOGIN_SUCCESS
// ACQUIRE_TOKEN_SUCCESS
// ACQUIRE_TOKEN_FAILURE
// LOGOUT_SUCCESS
// ACTIVE_ACCOUNT_CHANGED
});
```
MSAL Browser exposes `addEventCallback` for emitted auth events. ([Microsoft Learn][10])
Your app should own user activity policy:
```txt
last mouse/keyboard/touch activity
tab visibility
idle timeout
soft lock
hard logout
pause polling
refresh-on-focus
```
Example:
```ts
const ACTIVITY_EVENTS = [
"pointerdown",
"keydown",
"touchstart",
"scroll",
"focus",
] as const;
export function startActivityMonitoring() {
function markActivity() {
auth.patch({
lastUserActivityAt: Date.now(),
idle: false,
});
}
for (const eventName of ACTIVITY_EVENTS) {
window.addEventListener(eventName, markActivity, {
passive: true,
capture: true,
});
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
markActivity();
// Optional: warm important scopes on resume.
void auth.warmAccessToken(["api://mosaic/client.read"]);
}
});
}
```
---
## 16. Logout behavior
Use MSAL logout APIs for actual logout.
```ts
await msal.logoutRedirect({
account: auth.getState().activeAccount ?? undefined,
});
```
`logoutRedirect()` clears MSAL’s local token cache and redirects to the server sign-out page. Microsoft warns that skipping server sign-out leaves the server session active, meaning the user may be signed back in without credentials. ([Microsoft Learn][11])
App-level idle behavior can choose between:
```txt
soft idle:
mark idle, hide sensitive UI, pause polling
hard idle:
clear app state, require “Continue”
security logout:
call logoutRedirect()
```
---
## 17. Recommended end-to-end flow
```txt
User navigates to /clients/123
clientLoader runs
requireAuth(request)
auth.start()
- initialize MSAL if needed
- handle redirect response if using global bootstrap
- restore active account
- publish auth state
No active account?
→ redirect /login?returnTo=/clients/123
Active account exists
mosaicService().getClientSummary("123")
authorizedFetch()
auth.getAccessToken(["api://mosaic/client.read"])
msal.acquireTokenSilent(...)
Silent success?
→ send request with bearer token
Interaction required?
→ redirect /login?returnTo=/clients/123
Backend validates token
Route renders data
```
---
## 18. Recommended file layout
```txt
app/
auth/
msal.client.ts
auth-store.client.ts
require-auth.client.ts
authorized-fetch.client.ts
errors.ts
services/
mosaic-service.ts
mosaic-service-factory.client.ts
routes/
login.tsx
auth.callback.tsx
clients.$clientId.tsx
```
---
## 19. Core implementation sketch
### `msal.client.ts`
```ts
import {
PublicClientApplication,
BrowserCacheLocation,
} from "@azure/msal-browser";
export const msal = new PublicClientApplication({
auth: {
clientId: import.meta.env.VITE_MSAL_CLIENT_ID,
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_MSAL_TENANT_ID}`,
redirectUri: `${window.location.origin}/auth/callback`,
postLogoutRedirectUri: window.location.origin,
},
cache: {
cacheLocation: BrowserCacheLocation.SessionStorage,
},
});
```
### `auth-store.client.ts`
```ts
import { useSyncExternalStore } from "react";
import {
AccountInfo,
EventMessageUtils,
EventType,
InteractionRequiredAuthError,
InteractionStatus,
} from "@azure/msal-browser";
import { msal } from "./msal.client";
export type AuthSnapshot = {
initialized: boolean;
inProgress: InteractionStatus;
accounts: AccountInfo[];
activeAccount: AccountInfo | null;
isAuthenticated: boolean;
error: Error | null;
};
let snapshot: AuthSnapshot = {
initialized: false,
inProgress: InteractionStatus.Startup,
accounts: [],
activeAccount: null,
isAuthenticated: false,
error: null,
};
let startPromise: Promise<void> | undefined;
let eventsRegistered = false;
const listeners = new Set<() => void>();
function publish(patch: Partial<AuthSnapshot> = {}) {
const accounts = msal.getAllAccounts();
const activeAccount = msal.getActiveAccount() ?? chooseAccount(accounts);
snapshot = {
...snapshot,
...patch,
accounts,
activeAccount,
isAuthenticated: Boolean(activeAccount),
};
listeners.forEach((listener) => listener());
}
function chooseAccount(accounts: AccountInfo[]) {
if (accounts.length === 1) {
msal.setActiveAccount(accounts[0]);
return accounts[0];
}
return null;
}
function registerEventsOnce() {
if (eventsRegistered) return;
eventsRegistered = true;
msal.addEventCallback((message) => {
const nextStatus = EventMessageUtils.getInteractionStatusFromEvent(
message,
snapshot.inProgress,
);
if (
message.eventType === EventType.LOGIN_SUCCESS ||
message.eventType === EventType.ACQUIRE_TOKEN_SUCCESS
) {
const result = message.payload as { account?: AccountInfo };
if (result.account) {
msal.setActiveAccount(result.account);
}
}
if (message.eventType === EventType.LOGOUT_SUCCESS) {
msal.setActiveAccount(null);
}
publish({
inProgress: nextStatus ?? snapshot.inProgress,
});
});
}
async function startInternal() {
await msal.initialize();
registerEventsOnce();
const result = await msal.handleRedirectPromise();
if (result?.account) {
msal.setActiveAccount(result.account);
} else {
chooseAccount(msal.getAllAccounts());
}
publish({
initialized: true,
inProgress: InteractionStatus.None,
error: null,
});
}
export const auth = {
async start() {
if (!startPromise) {
startPromise = startInternal().catch((error) => {
publish({
initialized: true,
inProgress: InteractionStatus.None,
error,
});
throw error;
});
}
return startPromise;
},
getState() {
return snapshot;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async login() {
await this.start();
await msal.loginRedirect({
scopes: ["openid", "profile"],
});
},
async logout() {
await this.start();
await msal.logoutRedirect({
account: this.getState().activeAccount ?? undefined,
});
},
async getAccessToken(scopes: string[]) {
await this.start();
const account = this.getState().activeAccount;
if (!account) {
throw new LoginRequiredError();
}
try {
const result = await msal.acquireTokenSilent({
account,
scopes,
});
return result.accessToken;
} catch (error) {
if (error instanceof InteractionRequiredAuthError) {
throw new ReauthenticationRequiredError();
}
throw error;
}
},
};
export function useAuth() {
return useSyncExternalStore(auth.subscribe, auth.getState, auth.getState);
}
export function useIsAuthenticated() {
return useAuth().isAuthenticated;
}
```
### `require-auth.client.ts`
```ts
import { redirect } from "react-router";
import { auth } from "./auth-store.client";
function getReturnTo(request: Request) {
const url = new URL(request.url);
return `${url.pathname}${url.search}`;
}
export async function requireAuth(request: Request) {
await auth.start();
const { activeAccount } = auth.getState();
if (!activeAccount) {
throw redirect(`/login?returnTo=${encodeURIComponent(getReturnTo(request))}`);
}
return activeAccount;
}
```
### `authorized-fetch.client.ts`
```ts
import { auth } from "./auth-store.client";
export async function authorizedFetch(
input: RequestInfo | URL,
init: RequestInit = {},
) {
const token = await auth.getAccessToken(["api://mosaic/client.read"]);
const response = await fetch(input, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
},
});
if (response.status === 401) {
throw new ReauthenticationRequiredError();
}
if (response.status === 403) {
throw new ForbiddenError();
}
return response;
}
```
### `mosaic-service-factory.client.ts`
```ts
import { authorizedFetch } from "../auth/authorized-fetch.client";
import { MosaicService } from "./mosaic-service";
export const mosaicService = () =>
new MosaicService({
baseUrl: "/api/mosaic",
fetch: authorizedFetch,
});
```
### Route module
```ts
import type { Route } from "./+types/clients.$clientId";
import { redirect } from "react-router";
import { requireAuth } from "../auth/require-auth.client";
import { mosaicService } from "../services/mosaic-service-factory.client";
import { isInteractionRequired } from "../auth/errors";
export async function clientLoader({
request,
params,
}: Route.ClientLoaderArgs) {
await requireAuth(request);
try {
return await mosaicService().getClientSummary(params.clientId);
} catch (error) {
if (isInteractionRequired(error)) {
const url = new URL(request.url);
const returnTo = `${url.pathname}${url.search}`;
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
}
throw error;
}
}
```
---
## 20. Final rules of thumb
```txt
Use @azure/msal-browser as the source of truth.
Only use @azure/msal-react if you want component convenience APIs.
Expose your own auth facade with:
start()
getState()
subscribe()
getAccessToken()
login()
logout()
Call requireAuth() from protected clientLoaders.
Make auth.start() idempotent with a shared promise.
Do not store access tokens in app state.
Do not capture bearer tokens in service constructors.
Acquire tokens at the API boundary with acquireTokenSilent().
Redirect to /login only when:
no active account exists, or
acquireTokenSilent() fails with interaction required.
Use authorizedFetch/service middleware to attach Authorization headers.
Let the backend validate bearer tokens.
Use MSAL events for auth state changes.
Use your own activity monitor for idle/session UX.
```
[1]: https://learn.microsoft.com/en-us/entra/msal/javascript/react/getting-started "Get started with MSAL React - Microsoft Authentication Library for JavaScript | Microsoft Learn"
[2]: https://learn.microsoft.com/en-us/entra/msal/javascript/react/migration-guide "Migration Guide from MSAL v1 to MSAL React and MSAL Browser - Microsoft Authentication Library for JavaScript | Microsoft Learn"
[3]: https://reactrouter.com/start/framework/route-module "Route Module | React Router"
[4]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/initialization "Initialize MSAL Browser - Microsoft Authentication Library for JavaScript | Microsoft Learn"
[5]: https://azuread.github.io/microsoft-authentication-library-for-js/ref/classes/_azure_msal_browser.PublicClientApplication.html?utm_source=chatgpt.com "PublicClientApplication | Documentation"
[6]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/errors?utm_source=chatgpt.com "Common errors in MSAL JS"
[7]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/acquire-token "Acquiring and using an access token - Microsoft Authentication Library for JavaScript | Microsoft Learn"
[8]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/caching "Caching in MSAL.js - Microsoft Authentication Library for JavaScript | Microsoft Learn"
[9]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/token-lifetimes "Manage token lifetimes - Microsoft Authentication Library for JavaScript | Microsoft Learn"
[10]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/events?utm_source=chatgpt.com "Events in MSAL Browser"
[11]: https://learn.microsoft.com/en-us/entra/msal/javascript/browser/logout "Sign out users - Microsoft Authentication Library for JavaScript | Microsoft Learn"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment