Microsoft/Azure AD authentication for Next.js App Router. Minimal setup, full TypeScript support, production-ready.
Current version: 5.1.0
npm install @chemmangat/msal-next @azure/msal-browser @azure/msal-reactOr scaffold everything automatically with the CLI:
npx @chemmangat/msal-next-cli initThe init command interactively asks for your client ID, tenant ID, authority type, and cache location, then creates .env.local, updates app/layout.tsx with MSALProvider, and generates a starter app/auth/page.tsx.
To migrate an existing project from popup-based auth to redirect:
npx @chemmangat/msal-next-cli migrate1. Add the provider to your layout
// app/layout.tsx
import { MSALProvider } from '@chemmangat/msal-next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<MSALProvider
clientId={process.env.NEXT_PUBLIC_AZURE_AD_CLIENT_ID!}
tenantId={process.env.NEXT_PUBLIC_AZURE_AD_TENANT_ID!}
>
{children}
</MSALProvider>
</body>
</html>
);
}MSALProvider is already marked 'use client' internally — no need to add it to your layout.
2. Add a sign-in button
// app/page.tsx
'use client';
import { MicrosoftSignInButton, SignOutButton, useMsalAuth } from '@chemmangat/msal-next';
export default function HomePage() {
const { isAuthenticated, account } = useMsalAuth();
return isAuthenticated ? (
<div>
<p>Welcome, {account?.name}</p>
<SignOutButton />
</div>
) : (
<MicrosoftSignInButton />
);
}3. Set environment variables
# .env.local
NEXT_PUBLIC_AZURE_AD_CLIENT_ID=your-client-id
NEXT_PUBLIC_AZURE_AD_TENANT_ID=your-tenant-id<MSALProvider
clientId="..."
tenantId="..." // optional, single-tenant only
authorityType="common" // 'common' | 'organizations' | 'consumers' | 'tenant'
scopes={['User.Read']}
redirectUri="https://myapp.com"
cacheLocation="sessionStorage" // 'sessionStorage' | 'localStorage' | 'memoryStorage'
enableLogging={false}
autoRefreshToken={true}
refreshBeforeExpiry={300}
allowedRedirectUris={['https://myapp.com']}
protection={{ defaultRedirectTo: '/login' }}
>
{children}
</MSALProvider>| Prop | Type | Default | Description |
|---|---|---|---|
clientId |
string |
required | Azure AD Application (client) ID |
tenantId |
string |
— | Directory (tenant) ID, single-tenant only |
authorityType |
string |
'common' |
Authority type |
redirectUri |
string |
window.location.origin |
Redirect URI after auth |
postLogoutRedirectUri |
string |
redirectUri |
Redirect URI after logout |
scopes |
string[] |
['User.Read'] |
Default scopes |
cacheLocation |
string |
'sessionStorage' |
Token cache location |
enableLogging |
boolean |
false |
Debug logging |
autoRefreshToken |
boolean |
false |
Auto-refresh tokens before expiry |
refreshBeforeExpiry |
number |
300 |
Seconds before expiry to refresh |
allowedRedirectUris |
string[] |
— | Whitelist redirect URIs |
protection |
AuthProtectionConfig |
— | Zero-config route protection |
<MicrosoftSignInButton
variant="dark" // 'dark' | 'light'
size="medium" // 'small' | 'medium' | 'large'
text="Sign in with Microsoft"
scopes={['User.Read']}
onSuccess={() => {}}
onError={(error) => {}}
/><SignOutButton
variant="light"
size="medium"
onSuccess={() => {}}
onError={(error) => {}}
/>Protects content and redirects unauthenticated users to login.
<AuthGuard
loadingComponent={<div>Loading...</div>}
fallbackComponent={<div>Redirecting...</div>}
scopes={['User.Read']}
onAuthRequired={() => {}}
>
<ProtectedContent />
</AuthGuard><UserAvatar size={48} showTooltip fallbackImage="/avatar.png" /><AccountSwitcher
variant="default" // 'default' | 'compact' | 'minimal'
maxAccounts={5}
showAvatars
showAddButton
showRemoveButton
onSwitch={(account) => {}}
onAdd={() => {}}
onRemove={(account) => {}}
// v5.0.0 — custom account row rendering
renderAccount={(account, isActive) => (
<div style={{ fontWeight: isActive ? 'bold' : 'normal' }}>
{account.name} ({account.username})
</div>
)}
/><AccountList
showAvatars
showDetails
showActiveIndicator
clickToSwitch
orientation="vertical" // 'vertical' | 'horizontal'
onAccountClick={(account) => {}}
// v5.0.0 — custom account row rendering
renderAccount={(account, isActive) => (
<div style={{ color: isActive ? 'blue' : 'black' }}>
{account.name} — {account.username}
</div>
)}
/><MSALProvider
clientId={process.env.NEXT_PUBLIC_AZURE_AD_CLIENT_ID!}
multiTenant={{
type: 'multi', // 'single' | 'multi' | 'organizations' | 'consumers' | 'common'
allowList: ['contoso.com', 'fabrikam.com'], // tenant IDs or domains
blockList: ['competitor.com'],
requireType: 'Member', // block B2B guests
requireMFA: true, // require MFA claim
}}
onTenantDenied={(reason) => router.push(`/denied?reason=${encodeURIComponent(reason)}`)}
>import { useTenant } from '@chemmangat/msal-next';
const {
tenantId, // resource tenant ID
tenantDomain, // e.g. 'contoso.com'
isGuestUser, // true for B2B guests
homeTenantId, // user's home tenant
resourceTenantId, // tenant the token was issued for
claims, // raw idTokenClaims
isAuthenticated,
} = useTenant();// app/admin/page.tsx
export const auth = {
required: true,
tenant: {
allowList: ['contoso.com'],
requireMFA: true,
},
};// middleware.ts
export const middleware = createAuthMiddleware({
protectedRoutes: ['/dashboard'],
tenantConfig: { allowList: ['contoso.com'], requireMFA: true },
tenantDeniedPath: '/unauthorized',
});const { acquireTokenForTenant } = useMsalAuth();
const token = await acquireTokenForTenant('target-tenant-id', ['User.Read']);const {
account, // AccountInfo | null
accounts, // AccountInfo[]
isAuthenticated, // boolean
inProgress, // boolean
loginRedirect, // (scopes?: string[]) => Promise<void>
logoutRedirect, // () => Promise<void>
acquireToken, // (scopes: string[]) => Promise<string> — silent with redirect fallback
acquireTokenSilent, // (scopes: string[]) => Promise<string> — silent only
acquireTokenRedirect, // (scopes: string[]) => Promise<void>
clearSession, // () => Promise<void> — clears cache without Microsoft logout
acquireTokenForTenant,// (tenantId: string, scopes: string[]) => Promise<string> — cross-tenant (v5.1.0)
} = useMsalAuth();const {
profile, // UserProfile | null (30+ fields from MS Graph /me)
loading, // boolean
error, // Error | null
refetch, // () => Promise<void>
clearCache, // () => void
} = useUserProfile();
// Extend with custom fields
interface MyProfile extends UserProfile { customField: string }
const { profile } = useUserProfile<MyProfile>();const graph = useGraphApi();
const user = await graph.get('/me');
const result = await graph.post('/me/messages', body);
await graph.put('/me/photo/$value', blob);
await graph.patch('/me', { displayName: 'New Name' });
await graph.delete('/me/messages/{id}');
const data = await graph.request('/me', { version: 'beta' });const {
roles, // string[]
groups, // string[]
loading, // boolean
error, // Error | null
hasRole, // (role: string) => boolean
hasGroup, // (groupId: string) => boolean
hasAnyRole, // (roles: string[]) => boolean
hasAllRoles, // (roles: string[]) => boolean
refetch, // () => Promise<void>
} = useRoles();Monitors token expiry and refreshes tokens automatically in the background. Use this when you want to show a session-expiry warning or trigger a manual refresh independently of MSALProvider's autoRefreshToken prop.
useTokenRefresh(options?: UseTokenRefreshOptions): UseTokenRefreshReturnOptions (UseTokenRefreshOptions):
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enable automatic background refresh |
refreshBeforeExpiry |
number |
300 |
Seconds before expiry to trigger refresh |
scopes |
string[] |
['User.Read'] |
Scopes to refresh |
onRefresh |
(expiresIn: number) => void |
— | Called after a successful refresh |
onError |
(error: Error) => void |
— | Called when refresh fails |
Return values (UseTokenRefreshReturn):
| Value | Type | Description |
|---|---|---|
expiresIn |
number | null |
Seconds until the current token expires |
isExpiringSoon |
boolean |
true when within refreshBeforeExpiry window |
refresh |
() => Promise<void> |
Manually trigger a token refresh |
lastRefresh |
Date | null |
Timestamp of the last successful refresh |
'use client';
import { useTokenRefresh } from '@chemmangat/msal-next';
export default function SessionBanner() {
const { expiresIn, isExpiringSoon, refresh, lastRefresh } = useTokenRefresh({
enabled: true,
refreshBeforeExpiry: 300,
scopes: ['User.Read'],
onRefresh: (expiresIn) => console.log(`Refreshed — expires in ${expiresIn}s`),
onError: (error) => console.error('Refresh failed', error),
});
if (isExpiringSoon) {
return (
<div>
Session expires in {Math.floor((expiresIn ?? 0) / 60)} minutes.{' '}
<button onClick={refresh}>Stay signed in</button>
{lastRefresh && <span> Last refreshed: {lastRefresh.toLocaleTimeString()}</span>}
</div>
);
}
return null;
}Manages multiple simultaneously signed-in Microsoft accounts. Use this when your app needs to support users working across multiple tenants or with both personal and work accounts. Accepts an optional defaultScopes argument used when adding a new account.
useMultiAccount(defaultScopes?: string[]): UseMultiAccountReturnReturn values (UseMultiAccountReturn):
| Value | Type | Description |
|---|---|---|
accounts |
AccountInfo[] |
All accounts currently in the MSAL cache |
activeAccount |
AccountInfo | null |
The currently active account |
hasMultipleAccounts |
boolean |
true when more than one account is cached |
accountCount |
number |
Total number of cached accounts |
inProgress |
boolean |
true while an MSAL interaction is running |
switchAccount |
(account: AccountInfo) => void |
Set a different account as active |
addAccount |
(scopes?: string[]) => Promise<void> |
Sign in with an additional account |
removeAccount |
(account: AccountInfo) => Promise<void> |
Remove an account from the cache |
signOutAccount |
(account: AccountInfo) => Promise<void> |
Sign out a specific account |
signOutAll |
() => Promise<void> |
Sign out all accounts |
getAccountByUsername |
(username: string) => AccountInfo | undefined |
Look up an account by username |
getAccountById |
(homeAccountId: string) => AccountInfo | undefined |
Look up an account by home account ID |
isActiveAccount |
(account: AccountInfo) => boolean |
Check whether a given account is active |
'use client';
import { useMultiAccount } from '@chemmangat/msal-next';
export default function AccountManager() {
const {
accounts,
activeAccount,
hasMultipleAccounts,
accountCount,
inProgress,
switchAccount,
addAccount,
signOutAccount,
signOutAll,
isActiveAccount,
} = useMultiAccount(['User.Read']);
return (
<div>
<p>Signed in as: {activeAccount?.name} ({activeAccount?.username})</p>
<p>{accountCount} account{accountCount !== 1 ? 's' : ''} cached</p>
{hasMultipleAccounts && (
<ul>
{accounts.map((account) => (
<li key={account.homeAccountId}>
{account.name}
{isActiveAccount(account) && ' (active)'}
<button onClick={() => switchAccount(account)} disabled={isActiveAccount(account)}>
Switch
</button>
<button onClick={() => signOutAccount(account)}>Sign out</button>
</li>
))}
</ul>
)}
<button onClick={() => addAccount()} disabled={inProgress}>Add another account</button>
<button onClick={() => signOutAll()} disabled={inProgress}>Sign out all</button>
</div>
);
}const ProtectedPage = withAuth(MyPage, {
loadingComponent: <Spinner />,
scopes: ['User.Read'],
});Wraps a page component with authentication and optional role-based access control. Returns a new component that checks auth before rendering. Takes a PageAuthConfig as the second argument and an optional AuthProtectionConfig as the third for global defaults.
withPageAuth<P>(
Component: ComponentType<P>,
authConfig: PageAuthConfig,
globalConfig?: AuthProtectionConfig
): ComponentType<P>PageAuthConfig options:
| Option | Type | Default | Description |
|---|---|---|---|
required |
boolean |
false |
Whether authentication is required |
roles |
string[] |
— | User must have at least one of these roles (from idTokenClaims.roles) |
redirectTo |
string |
'/login' |
Where to redirect unauthenticated users |
loading |
ReactNode |
— | Component shown while auth state is resolving |
unauthorized |
ReactNode |
— | Component shown instead of redirecting when access is denied |
validate |
(account: any) => boolean | Promise<boolean> |
— | Custom access check; return true to allow, false to deny |
AuthProtectionConfig options (global defaults, third argument):
| Option | Type | Default | Description |
|---|---|---|---|
defaultRedirectTo |
string |
'/login' |
Fallback redirect for unauthenticated users |
defaultLoading |
ReactNode |
— | Fallback loading component |
defaultUnauthorized |
ReactNode |
— | Fallback unauthorized component |
debug |
boolean |
false |
Log auth decisions to the console |
// app/dashboard/page.tsx
'use client';
import { withPageAuth } from '@chemmangat/msal-next';
function Dashboard() {
return <div>Dashboard — only admins and editors can see this</div>;
}
export default withPageAuth(
Dashboard,
{
required: true,
roles: ['Admin', 'Editor'],
redirectTo: '/login',
loading: <div>Checking access...</div>,
unauthorized: <div>You do not have permission to view this page.</div>,
},
{
debug: process.env.NODE_ENV === 'development',
}
);Custom validate — restrict by email domain:
export default withPageAuth(Dashboard, {
required: true,
validate: (account) => account.username.endsWith('@company.com'),
unauthorized: <div>Only company accounts are allowed.</div>,
});The underlying component that withPageAuth uses internally. Use it directly when you need to protect arbitrary JSX rather than a whole page component — for example inside a layout or a slot.
Props (ProtectedPageProps):
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
required | Content to render when access is granted |
config |
PageAuthConfig |
required | Per-page auth rules (same shape as withPageAuth second arg) |
defaultRedirectTo |
string |
'/login' |
Redirect path when unauthenticated |
defaultLoading |
ReactNode |
— | Loading component while auth resolves |
defaultUnauthorized |
ReactNode |
— | Component shown when access is denied |
debug |
boolean |
false |
Log auth decisions to the console |
// app/settings/layout.tsx
'use client';
import { ProtectedPage } from '@chemmangat/msal-next';
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<ProtectedPage
config={{
required: true,
roles: ['Admin'],
redirectTo: '/login',
}}
defaultLoading={<div>Loading...</div>}
defaultUnauthorized={<div>Admins only.</div>}
>
{children}
</ProtectedPage>
);
}// app/profile/page.tsx (Server Component)
import { getServerSession } from '@chemmangat/msal-next/server';
import { redirect } from 'next/navigation';
export default async function ProfilePage() {
const session = await getServerSession();
if (!session.isAuthenticated) redirect('/login');
return <div>Welcome, {session.username}</div>;
}Creates a Next.js Edge middleware function that enforces authentication at the routing level — before any page or API route renders. Use this to protect entire route groups without adding AuthGuard or withPageAuth to every page.
The middleware reads a session cookie (msal.account by default) to determine auth state. Because MSAL runs in the browser, the cookie must be set client-side after login (e.g. via setServerSessionCookie from @chemmangat/msal-next/server). You can also supply a custom isAuthenticated function to integrate your own session store.
createAuthMiddleware(config?: AuthMiddlewareConfig): (request: NextRequest) => Promise<NextResponse>AuthMiddlewareConfig options:
| Option | Type | Default | Description |
|---|---|---|---|
protectedRoutes |
string[] |
— | Paths that require authentication. Unauthenticated requests redirect to loginPath |
publicOnlyRoutes |
string[] |
— | Paths only accessible when NOT authenticated (e.g. /login). Authenticated users are redirected to redirectAfterLogin |
loginPath |
string |
'/login' |
Where to redirect unauthenticated users |
redirectAfterLogin |
string |
'/' |
Where to redirect authenticated users away from publicOnlyRoutes |
sessionCookie |
string |
'msal.account' |
Cookie name used to detect an active session |
isAuthenticated |
(request: NextRequest) => boolean | Promise<boolean> |
— | Custom auth check; overrides the default cookie check |
debug |
boolean |
false |
Log routing decisions to the console |
// middleware.ts
import { createAuthMiddleware } from '@chemmangat/msal-next';
export const middleware = createAuthMiddleware({
protectedRoutes: ['/dashboard', '/profile', '/settings', '/api/protected'],
publicOnlyRoutes: ['/login', '/signup'],
loginPath: '/login',
redirectAfterLogin: '/dashboard',
sessionCookie: 'msal.account',
debug: process.env.NODE_ENV === 'development',
});
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Custom isAuthenticated — use your own session store:
// middleware.ts
import { createAuthMiddleware } from '@chemmangat/msal-next';
import { verifySession } from './lib/session';
export const middleware = createAuthMiddleware({
protectedRoutes: ['/dashboard'],
loginPath: '/login',
isAuthenticated: async (request) => {
const token = request.cookies.get('session-token')?.value;
if (!token) return false;
return verifySession(token);
},
});
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};import { useMsalAuth, wrapMsalError } from '@chemmangat/msal-next';
const { loginRedirect } = useMsalAuth();
const handleLogin = async () => {
try {
await loginRedirect();
} catch (error) {
const msalError = wrapMsalError(error);
if (msalError.isUserCancellation()) return;
console.error(msalError.toConsoleString());
}
};msal-next/
├── packages/core/ # npm package source
└── src/ # documentation website
See CONTRIBUTING.md.
MIT © Chemmangat