Skip to content
KB

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.

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:

examples/javascript/entra-id-client-sign-in.js
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
examples/javascript/entra-id-client-sign-in.js
// @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:

examples/javascript/entra-id-client-sign-in.js
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
examples/javascript/entra-id-client-sign-in.js
// @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:

examples/javascript/entra-id-client-sign-in.js
const loginResponse = await instance.loginPopup({ scopes: ["User.Read"] });
const account = loginResponse.account;

compiled and run by the build

Show the whole file
examples/javascript/entra-id-client-sign-in.js
// @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;

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:

examples/javascript/entra-id-client-sign-in.js
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
examples/javascript/entra-id-client-sign-in.js
// @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).

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:

examples/dotnet/entra-id-client-sign-in.cs
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));

compiled and run by the build

Show the whole file
examples/dotnet/entra-id-client-sign-in.cs
#: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.

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.

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.

How do I attach a bearer token to every HttpClient request with a DelegatingHandler? (dotnet, howto)How do I attach a bearer tokento every HttpClient request…What is the right way to authenticate a .NET desktop client against an API that issues JWTs? (dotnet, howto)What is the right way toauthenticate a .NET desktop…How do I coordinate Microsoft Entra ID sign-in between a single-page app and a separately hosted backend API? (identity, howto)How do I coordinate MicrosoftEntra ID sign-in between a…What is single sign-on and how do OAuth 2.0 and OpenID Connect implement it with Microsoft Entra ID? (identity, howto)What is single sign-on and howdo OAuth 2.0 and OpenID Connect…What database schema links an Entra ID (or other SSO) identity to an existing local user table? (identity, howto)What database schema links anEntra ID (or other SSO)…How do I validate Microsoft Entra ID access tokens on the server to protect an API? (identity, howto)How do I validate MicrosoftEntra ID access tokens on the…
Open in the graph

Source: https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-single-page-app-sign-in

History: 3 changes
  1. Body changed; example changed
  2. Made public
  3. Created