How do I add Microsoft Entra ID sign-in to a web app or single-page app?
howto · identity · entra-id · oauth · oidc · sso
Register the app in the Microsoft Entra admin center under the matching platform type - Single-page application for a SPA, Web for a server-rendered app. Then run authorization code with PKCE through a library instead of by hand: MSAL.js for a SPA, Microsoft.Identity.Web for ASP.NET Core. Call the silent token method first and fall back to an interactive one only when it throws, so a returning user isn't reprompted every page load.
Register the app before writing any code
Section titled “Register the app before writing any code”In the Microsoft Entra admin center, under Identity > Applications > App registrations, “New registration” asks for a name (shown to the user on the consent screen, and changeable later) and which accounts may sign in (this tenant only, any Microsoft Entra tenant, or personal Microsoft accounts too). After registration, the Overview page shows the Application (client) ID and Directory (tenant) ID every library call below needs.
The one setting that has to match the app’s shape is the redirect URI’s platform type, added from
“Add a platform” on the same registration: Single-page application for anything running MSAL.js
or a framework wrapper around it, Web for a server-rendered app that holds a client secret or
certificate. The platform type isn’t cosmetic - registering a SPA’s redirect URI under “Web” fails
its token requests with a AADSTS9002326 cross-origin error, because only the SPA platform type
enables the CORS support and public-client PKCE handling the browser flow depends on; a SPA has no
way to keep a client secret confidential, so it must never be registered as a confidential (Web)
client no matter how the redirect URI looks.
Sign in from a single-page app with MSAL.js
Section titled “Sign in from a single-page app with MSAL.js”npm install @azure/msal-browser (or @azure/msal-react / @azure/msal-angular for those
frameworks) and configure a PublicClientApplication with the two values just recorded:
const msalConfig = { auth: { clientId: "<application-client-id>", authority: "https://login.microsoftonline.com/<directory-tenant-id>", redirectUri: "/", // must be registered as a Single-page application redirect URI }, cache: { cacheLocation: "sessionStorage", // safer default; see the trap below },};
const msalInstance = new PublicClientApplication(msalConfig);await msalInstance.initialize();compiled and run by the build
Show the whole file
// @azure/msal-browser needs a live browser - window, sessionStorage, a// hidden iframe for silent token renewal - none of which this headless// `node` run has, so PublicClientApplication and InteractionRequiredAuthError// below stand in for the real library, with the same async surface the// marked regions call: handleRedirectPromise, getAllAccounts, loginRedirect,// loginPopup, acquireTokenSilent, acquireTokenPopup. Everything reachable// without a real browser and a real Entra ID tenant is exercised: the// redirect guard on page load, both outcomes of acquireTokenSilent, and the// header the token lands in.
class InteractionRequiredAuthError extends Error { constructor(message) { super(message); this.name = "InteractionRequiredAuthError"; }}
class PublicClientApplication { constructor(config) { this.config = config; this._accounts = []; this._redirectResponse = null; this._silentResult = null; // a token string, or an Error to throw this.redirectCalls = 0; this.popupCalls = 0; }
async initialize() {}
async handleRedirectPromise() { return this._redirectResponse; }
getAllAccounts() { return this._accounts; }
async loginRedirect() { this.redirectCalls++; }
async loginPopup() { this.popupCalls++; const account = { username: "user@example.com" }; this._accounts = [account]; return { accessToken: "popup-token", account }; }
async acquireTokenSilent() { if (this._silentResult instanceof Error) throw this._silentResult; return { accessToken: this._silentResult }; }
async acquireTokenPopup() { this.popupCalls++; return { accessToken: "interactive-token" }; }}
const msalConfig = { auth: { clientId: "<application-client-id>", authority: "https://login.microsoftonline.com/<directory-tenant-id>", redirectUri: "/", // must be registered as a Single-page application redirect URI }, cache: { cacheLocation: "sessionStorage", // safer default; see the trap below },};
const msalInstance = new PublicClientApplication(msalConfig);await msalInstance.initialize();
async function signInIfNeeded(instance) { const loginRequest = { scopes: ["User.Read"] };
const redirectResponse = await instance.handleRedirectPromise(); if (redirectResponse === null && instance.getAllAccounts().length === 0) { await instance.loginRedirect(loginRequest); // navigates away; code below this never runs }}
async function signInPopup(instance) { const loginResponse = await instance.loginPopup({ scopes: ["User.Read"] }); const account = loginResponse.account; return account;}
let seenRequest;globalThis.fetch = async (url, init) => { seenRequest = { url, init }; return new Response(JSON.stringify({}), { status: 200 });};
async function acquireTokenAndCallGraph(instance) { const account = instance.getAllAccounts()[0]; const request = { scopes: ["User.Read"], account };
let accessToken; try { accessToken = (await instance.acquireTokenSilent(request)).accessToken; } catch (error) { if (error.name === "InteractionRequiredAuthError") { accessToken = (await instance.acquireTokenPopup(request)).accessToken; } else { throw error; } }
const response = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); return response;}
let failures = 0;function check(ok, what) { if (!ok) { console.error(`FAILED: ${what}`); failures++; }}
check(msalInstance.config.auth.clientId === "<application-client-id>", "the constructed instance keeps the config it was given");
{ const instance = new PublicClientApplication(msalConfig); await signInIfNeeded(instance); check(instance.redirectCalls === 1, "no redirect response and no cached account triggers loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "returning@example.com" }]; await signInIfNeeded(instance); check(instance.redirectCalls === 0, "a cached account skips loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); instance._redirectResponse = { account: { username: "fresh@example.com" } }; await signInIfNeeded(instance); check(instance.redirectCalls === 0, "a redirect response just returned from Entra ID skips a second loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); const account = await signInPopup(instance); check(instance.popupCalls === 1, "the pop-up variant signs in through loginPopup"); check(account.username === "user@example.com", "loginPopup's response carries the signed-in account");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "cached@example.com" }]; instance._silentResult = "cached-token"; const response = await acquireTokenAndCallGraph(instance); check(instance.popupCalls === 0, "a cached or silently refreshed token never falls back to an interactive call"); check(seenRequest.init.headers.Authorization === "Bearer cached-token", "the silent token is attached as a bearer credential"); check(response.status === 200, "the stubbed Graph call resolves");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "stale@example.com" }]; instance._silentResult = new InteractionRequiredAuthError("AADSTS50076: interaction required"); await acquireTokenAndCallGraph(instance); check(instance.popupCalls === 1, "InteractionRequiredAuthError falls back to acquireTokenPopup"); check(seenRequest.init.headers.Authorization === "Bearer interactive-token", "the interactive token is attached as a bearer credential");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "broken@example.com" }]; instance._silentResult = new Error("network down"); let thrown = null; try { await acquireTokenAndCallGraph(instance); } catch (error) { thrown = error; } check(thrown?.message === "network down", "any other acquireTokenSilent error is rethrown"); check(instance.popupCalls === 0, "an error that is not InteractionRequiredAuthError never falls back to an interactive call");}
console.log(failures === 0 ? "entra-id-client-sign-in (javascript): all claims hold" : `${failures} FAILED`);process.exitCode = failures === 0 ? 0 : 1;Sign-in is either a redirect or a pop-up; both run the same Authorization Code with PKCE flow behind
the scenes (dotnet/jwt-authentication-desktop-client
covers the same flow for a native desktop client instead of a browser). A pop-up keeps the host page’s
state untouched, which is why frameworks default to it; a redirect is the only option once a browser
policy blocks pop-ups outright. The redirect variant has to await handleRedirectPromise() once, on
every page load, to pick up the response MSAL is coming back from:
const loginRequest = { scopes: ["User.Read"] };
const redirectResponse = await instance.handleRedirectPromise();if (redirectResponse === null && instance.getAllAccounts().length === 0) { await instance.loginRedirect(loginRequest); // navigates away; code below this never runs}compiled and run by the build
Show the whole file
// @azure/msal-browser needs a live browser - window, sessionStorage, a// hidden iframe for silent token renewal - none of which this headless// `node` run has, so PublicClientApplication and InteractionRequiredAuthError// below stand in for the real library, with the same async surface the// marked regions call: handleRedirectPromise, getAllAccounts, loginRedirect,// loginPopup, acquireTokenSilent, acquireTokenPopup. Everything reachable// without a real browser and a real Entra ID tenant is exercised: the// redirect guard on page load, both outcomes of acquireTokenSilent, and the// header the token lands in.
class InteractionRequiredAuthError extends Error { constructor(message) { super(message); this.name = "InteractionRequiredAuthError"; }}
class PublicClientApplication { constructor(config) { this.config = config; this._accounts = []; this._redirectResponse = null; this._silentResult = null; // a token string, or an Error to throw this.redirectCalls = 0; this.popupCalls = 0; }
async initialize() {}
async handleRedirectPromise() { return this._redirectResponse; }
getAllAccounts() { return this._accounts; }
async loginRedirect() { this.redirectCalls++; }
async loginPopup() { this.popupCalls++; const account = { username: "user@example.com" }; this._accounts = [account]; return { accessToken: "popup-token", account }; }
async acquireTokenSilent() { if (this._silentResult instanceof Error) throw this._silentResult; return { accessToken: this._silentResult }; }
async acquireTokenPopup() { this.popupCalls++; return { accessToken: "interactive-token" }; }}
const msalConfig = { auth: { clientId: "<application-client-id>", authority: "https://login.microsoftonline.com/<directory-tenant-id>", redirectUri: "/", // must be registered as a Single-page application redirect URI }, cache: { cacheLocation: "sessionStorage", // safer default; see the trap below },};
const msalInstance = new PublicClientApplication(msalConfig);await msalInstance.initialize();
async function signInIfNeeded(instance) { const loginRequest = { scopes: ["User.Read"] };
const redirectResponse = await instance.handleRedirectPromise(); if (redirectResponse === null && instance.getAllAccounts().length === 0) { await instance.loginRedirect(loginRequest); // navigates away; code below this never runs }}
async function signInPopup(instance) { const loginResponse = await instance.loginPopup({ scopes: ["User.Read"] }); const account = loginResponse.account; return account;}
let seenRequest;globalThis.fetch = async (url, init) => { seenRequest = { url, init }; return new Response(JSON.stringify({}), { status: 200 });};
async function acquireTokenAndCallGraph(instance) { const account = instance.getAllAccounts()[0]; const request = { scopes: ["User.Read"], account };
let accessToken; try { accessToken = (await instance.acquireTokenSilent(request)).accessToken; } catch (error) { if (error.name === "InteractionRequiredAuthError") { accessToken = (await instance.acquireTokenPopup(request)).accessToken; } else { throw error; } }
const response = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); return response;}
let failures = 0;function check(ok, what) { if (!ok) { console.error(`FAILED: ${what}`); failures++; }}
check(msalInstance.config.auth.clientId === "<application-client-id>", "the constructed instance keeps the config it was given");
{ const instance = new PublicClientApplication(msalConfig); await signInIfNeeded(instance); check(instance.redirectCalls === 1, "no redirect response and no cached account triggers loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "returning@example.com" }]; await signInIfNeeded(instance); check(instance.redirectCalls === 0, "a cached account skips loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); instance._redirectResponse = { account: { username: "fresh@example.com" } }; await signInIfNeeded(instance); check(instance.redirectCalls === 0, "a redirect response just returned from Entra ID skips a second loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); const account = await signInPopup(instance); check(instance.popupCalls === 1, "the pop-up variant signs in through loginPopup"); check(account.username === "user@example.com", "loginPopup's response carries the signed-in account");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "cached@example.com" }]; instance._silentResult = "cached-token"; const response = await acquireTokenAndCallGraph(instance); check(instance.popupCalls === 0, "a cached or silently refreshed token never falls back to an interactive call"); check(seenRequest.init.headers.Authorization === "Bearer cached-token", "the silent token is attached as a bearer credential"); check(response.status === 200, "the stubbed Graph call resolves");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "stale@example.com" }]; instance._silentResult = new InteractionRequiredAuthError("AADSTS50076: interaction required"); await acquireTokenAndCallGraph(instance); check(instance.popupCalls === 1, "InteractionRequiredAuthError falls back to acquireTokenPopup"); check(seenRequest.init.headers.Authorization === "Bearer interactive-token", "the interactive token is attached as a bearer credential");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "broken@example.com" }]; instance._silentResult = new Error("network down"); let thrown = null; try { await acquireTokenAndCallGraph(instance); } catch (error) { thrown = error; } check(thrown?.message === "network down", "any other acquireTokenSilent error is rethrown"); check(instance.popupCalls === 0, "an error that is not InteractionRequiredAuthError never falls back to an interactive call");}
console.log(failures === 0 ? "entra-id-client-sign-in (javascript): all claims hold" : `${failures} FAILED`);process.exitCode = failures === 0 ? 0 : 1;The pop-up variant needs no such check on load - it resolves in place:
const loginResponse = await instance.loginPopup({ scopes: ["User.Read"] });const account = loginResponse.account;compiled and run by the build
Show the whole file
// @azure/msal-browser needs a live browser - window, sessionStorage, a// hidden iframe for silent token renewal - none of which this headless// `node` run has, so PublicClientApplication and InteractionRequiredAuthError// below stand in for the real library, with the same async surface the// marked regions call: handleRedirectPromise, getAllAccounts, loginRedirect,// loginPopup, acquireTokenSilent, acquireTokenPopup. Everything reachable// without a real browser and a real Entra ID tenant is exercised: the// redirect guard on page load, both outcomes of acquireTokenSilent, and the// header the token lands in.
class InteractionRequiredAuthError extends Error { constructor(message) { super(message); this.name = "InteractionRequiredAuthError"; }}
class PublicClientApplication { constructor(config) { this.config = config; this._accounts = []; this._redirectResponse = null; this._silentResult = null; // a token string, or an Error to throw this.redirectCalls = 0; this.popupCalls = 0; }
async initialize() {}
async handleRedirectPromise() { return this._redirectResponse; }
getAllAccounts() { return this._accounts; }
async loginRedirect() { this.redirectCalls++; }
async loginPopup() { this.popupCalls++; const account = { username: "user@example.com" }; this._accounts = [account]; return { accessToken: "popup-token", account }; }
async acquireTokenSilent() { if (this._silentResult instanceof Error) throw this._silentResult; return { accessToken: this._silentResult }; }
async acquireTokenPopup() { this.popupCalls++; return { accessToken: "interactive-token" }; }}
const msalConfig = { auth: { clientId: "<application-client-id>", authority: "https://login.microsoftonline.com/<directory-tenant-id>", redirectUri: "/", // must be registered as a Single-page application redirect URI }, cache: { cacheLocation: "sessionStorage", // safer default; see the trap below },};
const msalInstance = new PublicClientApplication(msalConfig);await msalInstance.initialize();
async function signInIfNeeded(instance) { const loginRequest = { scopes: ["User.Read"] };
const redirectResponse = await instance.handleRedirectPromise(); if (redirectResponse === null && instance.getAllAccounts().length === 0) { await instance.loginRedirect(loginRequest); // navigates away; code below this never runs }}
async function signInPopup(instance) { const loginResponse = await instance.loginPopup({ scopes: ["User.Read"] }); const account = loginResponse.account; return account;}
let seenRequest;globalThis.fetch = async (url, init) => { seenRequest = { url, init }; return new Response(JSON.stringify({}), { status: 200 });};
async function acquireTokenAndCallGraph(instance) { const account = instance.getAllAccounts()[0]; const request = { scopes: ["User.Read"], account };
let accessToken; try { accessToken = (await instance.acquireTokenSilent(request)).accessToken; } catch (error) { if (error.name === "InteractionRequiredAuthError") { accessToken = (await instance.acquireTokenPopup(request)).accessToken; } else { throw error; } }
const response = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); return response;}
let failures = 0;function check(ok, what) { if (!ok) { console.error(`FAILED: ${what}`); failures++; }}
check(msalInstance.config.auth.clientId === "<application-client-id>", "the constructed instance keeps the config it was given");
{ const instance = new PublicClientApplication(msalConfig); await signInIfNeeded(instance); check(instance.redirectCalls === 1, "no redirect response and no cached account triggers loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "returning@example.com" }]; await signInIfNeeded(instance); check(instance.redirectCalls === 0, "a cached account skips loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); instance._redirectResponse = { account: { username: "fresh@example.com" } }; await signInIfNeeded(instance); check(instance.redirectCalls === 0, "a redirect response just returned from Entra ID skips a second loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); const account = await signInPopup(instance); check(instance.popupCalls === 1, "the pop-up variant signs in through loginPopup"); check(account.username === "user@example.com", "loginPopup's response carries the signed-in account");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "cached@example.com" }]; instance._silentResult = "cached-token"; const response = await acquireTokenAndCallGraph(instance); check(instance.popupCalls === 0, "a cached or silently refreshed token never falls back to an interactive call"); check(seenRequest.init.headers.Authorization === "Bearer cached-token", "the silent token is attached as a bearer credential"); check(response.status === 200, "the stubbed Graph call resolves");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "stale@example.com" }]; instance._silentResult = new InteractionRequiredAuthError("AADSTS50076: interaction required"); await acquireTokenAndCallGraph(instance); check(instance.popupCalls === 1, "InteractionRequiredAuthError falls back to acquireTokenPopup"); check(seenRequest.init.headers.Authorization === "Bearer interactive-token", "the interactive token is attached as a bearer credential");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "broken@example.com" }]; instance._silentResult = new Error("network down"); let thrown = null; try { await acquireTokenAndCallGraph(instance); } catch (error) { thrown = error; } check(thrown?.message === "network down", "any other acquireTokenSilent error is rethrown"); check(instance.popupCalls === 0, "an error that is not InteractionRequiredAuthError never falls back to an interactive call");}
console.log(failures === 0 ? "entra-id-client-sign-in (javascript): all claims hold" : `${failures} FAILED`);process.exitCode = failures === 0 ? 0 : 1;Acquire a token afterward
Section titled “Acquire a token afterward”Every call for an access token goes through acquireTokenSilent first: MSAL checks its in-memory and
browser-storage cache for an unexpired token, silently redeems a refresh token if the access token
expired, and only fails when neither works (a revoked session, a Conditional Access policy that now
requires fresh interaction, or a third-party-cookie block on the hidden iframe MSAL uses for that
silent redemption). Only that failure - an InteractionRequiredAuthError - should fall back to an
interactive call, using the same pop-up-or-redirect choice as sign-in:
const account = instance.getAllAccounts()[0];const request = { scopes: ["User.Read"], account };
let accessToken;try { accessToken = (await instance.acquireTokenSilent(request)).accessToken;} catch (error) { if (error.name === "InteractionRequiredAuthError") { accessToken = (await instance.acquireTokenPopup(request)).accessToken; } else { throw error; }}
const response = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { Authorization: `Bearer ${accessToken}` },});compiled and run by the build
Show the whole file
// @azure/msal-browser needs a live browser - window, sessionStorage, a// hidden iframe for silent token renewal - none of which this headless// `node` run has, so PublicClientApplication and InteractionRequiredAuthError// below stand in for the real library, with the same async surface the// marked regions call: handleRedirectPromise, getAllAccounts, loginRedirect,// loginPopup, acquireTokenSilent, acquireTokenPopup. Everything reachable// without a real browser and a real Entra ID tenant is exercised: the// redirect guard on page load, both outcomes of acquireTokenSilent, and the// header the token lands in.
class InteractionRequiredAuthError extends Error { constructor(message) { super(message); this.name = "InteractionRequiredAuthError"; }}
class PublicClientApplication { constructor(config) { this.config = config; this._accounts = []; this._redirectResponse = null; this._silentResult = null; // a token string, or an Error to throw this.redirectCalls = 0; this.popupCalls = 0; }
async initialize() {}
async handleRedirectPromise() { return this._redirectResponse; }
getAllAccounts() { return this._accounts; }
async loginRedirect() { this.redirectCalls++; }
async loginPopup() { this.popupCalls++; const account = { username: "user@example.com" }; this._accounts = [account]; return { accessToken: "popup-token", account }; }
async acquireTokenSilent() { if (this._silentResult instanceof Error) throw this._silentResult; return { accessToken: this._silentResult }; }
async acquireTokenPopup() { this.popupCalls++; return { accessToken: "interactive-token" }; }}
const msalConfig = { auth: { clientId: "<application-client-id>", authority: "https://login.microsoftonline.com/<directory-tenant-id>", redirectUri: "/", // must be registered as a Single-page application redirect URI }, cache: { cacheLocation: "sessionStorage", // safer default; see the trap below },};
const msalInstance = new PublicClientApplication(msalConfig);await msalInstance.initialize();
async function signInIfNeeded(instance) { const loginRequest = { scopes: ["User.Read"] };
const redirectResponse = await instance.handleRedirectPromise(); if (redirectResponse === null && instance.getAllAccounts().length === 0) { await instance.loginRedirect(loginRequest); // navigates away; code below this never runs }}
async function signInPopup(instance) { const loginResponse = await instance.loginPopup({ scopes: ["User.Read"] }); const account = loginResponse.account; return account;}
let seenRequest;globalThis.fetch = async (url, init) => { seenRequest = { url, init }; return new Response(JSON.stringify({}), { status: 200 });};
async function acquireTokenAndCallGraph(instance) { const account = instance.getAllAccounts()[0]; const request = { scopes: ["User.Read"], account };
let accessToken; try { accessToken = (await instance.acquireTokenSilent(request)).accessToken; } catch (error) { if (error.name === "InteractionRequiredAuthError") { accessToken = (await instance.acquireTokenPopup(request)).accessToken; } else { throw error; } }
const response = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { Authorization: `Bearer ${accessToken}` }, }); return response;}
let failures = 0;function check(ok, what) { if (!ok) { console.error(`FAILED: ${what}`); failures++; }}
check(msalInstance.config.auth.clientId === "<application-client-id>", "the constructed instance keeps the config it was given");
{ const instance = new PublicClientApplication(msalConfig); await signInIfNeeded(instance); check(instance.redirectCalls === 1, "no redirect response and no cached account triggers loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "returning@example.com" }]; await signInIfNeeded(instance); check(instance.redirectCalls === 0, "a cached account skips loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); instance._redirectResponse = { account: { username: "fresh@example.com" } }; await signInIfNeeded(instance); check(instance.redirectCalls === 0, "a redirect response just returned from Entra ID skips a second loginRedirect");}
{ const instance = new PublicClientApplication(msalConfig); const account = await signInPopup(instance); check(instance.popupCalls === 1, "the pop-up variant signs in through loginPopup"); check(account.username === "user@example.com", "loginPopup's response carries the signed-in account");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "cached@example.com" }]; instance._silentResult = "cached-token"; const response = await acquireTokenAndCallGraph(instance); check(instance.popupCalls === 0, "a cached or silently refreshed token never falls back to an interactive call"); check(seenRequest.init.headers.Authorization === "Bearer cached-token", "the silent token is attached as a bearer credential"); check(response.status === 200, "the stubbed Graph call resolves");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "stale@example.com" }]; instance._silentResult = new InteractionRequiredAuthError("AADSTS50076: interaction required"); await acquireTokenAndCallGraph(instance); check(instance.popupCalls === 1, "InteractionRequiredAuthError falls back to acquireTokenPopup"); check(seenRequest.init.headers.Authorization === "Bearer interactive-token", "the interactive token is attached as a bearer credential");}
{ const instance = new PublicClientApplication(msalConfig); instance._accounts = [{ username: "broken@example.com" }]; instance._silentResult = new Error("network down"); let thrown = null; try { await acquireTokenAndCallGraph(instance); } catch (error) { thrown = error; } check(thrown?.message === "network down", "any other acquireTokenSilent error is rethrown"); check(instance.popupCalls === 0, "an error that is not InteractionRequiredAuthError never falls back to an interactive call");}
console.log(failures === 0 ? "entra-id-client-sign-in (javascript): all claims hold" : `${failures} FAILED`);process.exitCode = failures === 0 ? 0 : 1;Requesting a scopes array MSAL hasn’t already gotten consent for reprompts the user once, which is
why a page that calls several APIs is usually better off asking for every scope it needs up front,
at sign-in, rather than piecemeal on first use.
Strung together - the redirect check every page load starts with, and where acquireTokenSilent
falls back to an interactive call - the whole path from a page load to the bearer header an API call
carries looks like this. loginRedirect and acquireTokenPopup each run the same Authorization Code
with PKCE exchange drawn in
identity/oauth-oidc-sso-entra-id,
so it isn’t drawn a second time here:
flowchart TD
Load[Page loads] --> Redirect{handleRedirectPromise}
Redirect -- no response, no account --> LoginRedirect[loginRedirect]
LoginRedirect --> Away[Browser navigates away]
Redirect -- response or cached account --> Silent[acquireTokenSilent]
Silent -- cached or refreshed --> Token[Access token]
Silent -- InteractionRequiredAuthError --> Popup[acquireTokenPopup]
Silent -- other error --> Rethrow[Rethrown]
Popup --> Token
Token --> Header["Authorization: Bearer header"]The trap: localStorage, and the retired implicit grant
Section titled “The trap: localStorage, and the retired implicit grant”cacheLocation: "localStorage" survives a closed tab and gives single sign-on across tabs of the
same origin, which is tempting, but it also means a cross-site scripting bug anywhere on the page can
read every cached access and refresh token straight out of storage; sessionStorage (the default
above) confines that exposure to the one tab. Older tutorials and MSAL.js v1 code also still show the
OAuth 2.0 implicit grant (response_type=token or id_token token, tokens returned straight in the
URL fragment with no code exchange, no PKCE and no refresh token) - Microsoft Entra ID still accepts
it for compatibility, but MSAL.js v2+ defaults to Authorization Code with PKCE and there is no reason
to opt back into implicit for a new app: it cannot refresh tokens silently across a full page load
the way the code flow’s hidden-iframe or refresh-token redemption can, and it exposes the token to
anything that can read the URL (browser history, a referrer header, a proxy log).
Variant: a server-rendered web app
Section titled “Variant: a server-rendered web app”A web app that renders pages on the server is a confidential client, not a public one: it can hold a
client secret or certificate the browser never sees, so its app registration uses the Web
platform type, and the authorization code exchange happens server-to-server rather than from
JavaScript in the browser. For ASP.NET Core, Microsoft.Identity.Web wires the whole OpenID Connect
redirect and code exchange into the authentication middleware instead of a hand-rolled flow:
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));compiled and run by the build
Show the whole file
#:sdk Microsoft.NET.Sdk.Web#:package Microsoft.Identity.Web@4.15.0#:package xunit.assert@2.9.3
// AddMicrosoftIdentityWebApp wires the whole OpenID Connect redirect and// code exchange into the authentication middleware; there is no interactive// browser here to actually sign in with, so this proves what can be proven// without one: building the host does not throw with only the settings a// registration page hands out, it registers an "OpenIdConnect" authentication// scheme, and the options driving that scheme's redirect carry the Instance,// TenantId and ClientId read from configuration rather than a default. The// host is built and never run - app.Run() would block waiting for a real// request.using Microsoft.AspNetCore.Authentication;using Microsoft.AspNetCore.Authentication.OpenIdConnect;using Microsoft.Extensions.Options;using Microsoft.Identity.Web;using Xunit;
var builder = WebApplication.CreateBuilder();builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>{ ["AzureAd:Instance"] = "https://login.microsoftonline.com/", ["AzureAd:TenantId"] = "11111111-1111-1111-1111-111111111111", ["AzureAd:ClientId"] = "22222222-2222-2222-2222-222222222222", ["AzureAd:ClientSecret"] = "not-a-real-secret",});
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
using var app = builder.Build();
var schemeProvider = app.Services.GetRequiredService<IAuthenticationSchemeProvider>();var scheme = await schemeProvider.GetSchemeAsync(OpenIdConnectDefaults.AuthenticationScheme);Assert.NotNull(scheme);
var options = app.Services.GetRequiredService<IOptionsMonitor<OpenIdConnectOptions>>() .Get(OpenIdConnectDefaults.AuthenticationScheme);Assert.Equal("22222222-2222-2222-2222-222222222222", options.ClientId);Assert.Equal("https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/v2.0", options.Authority);
Console.WriteLine("entra-id-client-sign-in (dotnet): all claims hold");with an AzureAd section naming the same ClientId and TenantId, plus a ClientSecret (or
certificate) the SPA case never has. Signing a user in is then [Authorize] on a controller or page
triggering the challenge automatically, and acquiring a token for a downstream API is
ITokenAcquisition.GetAccessTokenForUserAsync(scopes), the server-side equivalent of
acquireTokenSilent above - the token cache lives server-side (in memory, distributed cache, or
Redis) rather than in the browser, which is what removes the localStorage exposure entirely for
this shape of app.
How to verify it worked
Section titled “How to verify it worked”Sign in once, then reload the page (or, for the redirect flow, close and reopen the tab): a
correctly wired app shows the signed-in state without a visible prompt, because
acquireTokenSilent found a live session. Decode the ID token at
jwt.ms or the browser’s own devtools to confirm the aud claim matches the
registered client ID and tid matches the expected tenant. The browser’s network tab shows the
concrete proof the flow ran as PKCE: a GET /authorize request carrying code_challenge and
code_challenge_method=S256, followed by a POST /token carrying code_verifier and no
client_secret for the SPA case. Revoking the session (or waiting out the refresh token’s lifetime)
and repeating the call should cleanly fall back to the interactive method instead of throwing an
error the app doesn’t catch.
When this does not apply
Section titled “When this does not apply”A service calling an API with no user present - a background job, a scheduled sync - has no
interactive user to redirect and needs the OAuth 2.0 client credentials grant instead, trading the
app’s own client secret or certificate for a token directly; see the client_credentials
example in dotnet/delegatinghandler-bearer-token
for that shape. A native desktop or mobile app, rather than a browser page or a server-rendered one,
still runs Authorization Code with PKCE but through the OS’s system browser and a loopback or custom
URI redirect instead of a web redirect URI -
dotnet/jwt-authentication-desktop-client covers
that case in full.
Source: https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-single-page-app-sign-in
History: 3 changes
- Body changed; example changed
- Made public
- Created