MSAL authentication in Vue 3 and Nuxt
How to set up Microsoft Entra ID sign-in in Vue 3 with vue3-msal-plugin and MSAL Browser v4: app registration, login, access tokens, route guards, and Nuxt.

Every time I added Microsoft Entra ID sign-in to a Vue 3 app, I wrote the same glue code. @azure/msal-browser gives you a PublicClientApplication and a set of promises, but nothing reactive. To get a sign-in button that updates on its own, you have to initialize MSAL before any other call, listen to its events to keep the account in sync, and turn the account list into something a template can read.
So I pulled that layer out into vue3-msal-plugin. It wraps @azure/msal-browser in a few Vue composables and leaves the MSAL API itself untouched.
This guide covers the whole path: registering the app in Entra ID, signing users in, reading auth state, getting access tokens, calling Microsoft Graph, protecting routes, and running it in Nuxt.
What you need first
Node.js 20 or newer, Vue 3.5 or newer, @azure/msal-browser 4.x, and an Entra ID tenant where you can register an application. The plugin declares Vue (^3.5.0) and @azure/msal-browser (^4.0.0) as peer dependencies. There's no Vue 2 version.
Registering the app in Entra ID
MSAL can't do anything until Entra ID knows about your app, so start in the Microsoft Entra admin center. Go to Identity → Applications → App registrations → New registration and fill in three fields.
- Name: an internal label. Users never see it.
- Supported account types: Accounts in this organizational directory only for a single-tenant app. The other options change your authority URL.
- Redirect URI: choose the Single-page application platform and enter
http://localhost:5173for a default Vite dev server.
The platform choice matters. Web expects a client secret, which a browser app has nowhere safe to keep, so MSAL uses authorization code flow with PKCE, and that only works with a SPA redirect URI. If sign-in fails with a cross-origin or unsupported-flow error, check this first. Leave the implicit grant checkboxes unticked, too. MSAL v2 and later don't use implicit flow.
From the Overview page, copy the Application (client) ID and the Directory (tenant) ID. Under API permissions, check that Microsoft Graph's delegated User.Read permission is listed (it usually is by default). The Graph example below needs it.
.env
VITE_CLIENT_ID="00000000-0000-0000-0000-000000000000"
VITE_AUTHORITY="https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111"
For a single-tenant app, the authority is https://login.microsoftonline.com/ plus your tenant ID. Multi-tenant apps use /organizations, and apps that accept personal Microsoft accounts use /common.
Every redirect URI you use in production has to be registered as well, including the post-logout one. Entra ID rejects unknown URIs, and the error shows up on Microsoft's sign-in page rather than in your console.
Installing the plugin
npm i vue3-msal-plugin @azure/msal-browser
The plugin takes an MSAL instance rather than a config object. Create the instance, initialize it, set the active account, and then install the plugin.
src/main.ts
import { createApp } from "vue";
import { msalPlugin, msalInstance } from "vue3-msal-plugin";
import type { Configuration, AuthenticationResult } from "@azure/msal-browser";
import { EventType } from "@azure/msal-browser";
import App from "./App.vue";
import router from "./router";
const msalConfig: Configuration = {
auth: {
clientId: import.meta.env.VITE_CLIENT_ID,
authority: import.meta.env.VITE_AUTHORITY,
redirectUri: "http://localhost:5173",
postLogoutRedirectUri: "http://localhost:5173",
},
cache: {
cacheLocation: "localStorage",
},
};
async function bootstrap() {
const instance = msalInstance(msalConfig);
// Loads the token cache. Every other MSAL call has to wait for this.
await instance.initialize();
// Restore the signed-in account after a page refresh.
const accounts = instance.getAllAccounts();
if (accounts.length > 0) {
instance.setActiveAccount(accounts[0]);
}
// A fresh sign-in does not set the active account for you.
instance.addEventCallback((event) => {
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
const payload = event.payload as AuthenticationResult;
instance.setActiveAccount(payload.account);
}
});
const app = createApp(App);
app.use(router);
app.use(msalPlugin, instance);
app.mount("#app");
}
bootstrap();
Four details in this file are easy to get wrong.
await instance.initialize() comes first. @azure/msal-browser has required it since v3, and in v4 the localStorage cache only loads during initialize(). Call getAllAccounts() before it and a signed-in user gets BrowserAuthError: uninitialized_public_client_application on every reload. A first sign-in works fine, so test with a reload.
setActiveAccount after getAllAccounts restores the session after a refresh. MSAL keeps the account in its cache but doesn't pick an active one, so without this, token calls fail even though the user is signed in.
The LOGIN_SUCCESS callback does the same for a first-time sign-in, which also leaves the active account unset.
cacheLocation: "localStorage" keeps the session across tabs and reloads. The default, sessionStorage, doesn't. The FAQ covers the security side of that choice.
You don't need to call handleRedirectPromise() yourself. The first useMsal() call in a component runs it, which completes a redirect sign-in when the user lands back on your app.
Signing in
useMsal() returns the MSAL instance plus some reactive state.
const { instance, accounts, inProgress, loginRequest, callMsGraph } = useMsal();
instance is the PublicClientApplication itself. accounts and inProgress are refs, and loginRequest defaults to { scopes: ['User.Read'] }, which is enough to sign in and read the user's own profile.
src/components/AuthButton.vue
<script setup lang="ts">
import { useMsal, useIsAuthenticated } from "vue3-msal-plugin";
const { instance, loginRequest } = useMsal();
const isAuthenticated = useIsAuthenticated();
const loginPopup = () => instance.loginPopup(loginRequest);
const loginRedirect = () => instance.loginRedirect(loginRequest);
const logoutPopup = () => instance.logoutPopup({ mainWindowRedirectUri: "/" });
const logoutRedirect = () => instance.logoutRedirect();
</script>
<template>
<button v-if="!isAuthenticated" @click="loginPopup">Sign in</button>
<button v-else @click="logoutPopup">Sign out</button>
</template>
Popup is the better default for most apps. Your app stays mounted, so its state survives the sign-in. Browsers only allow popups that come from a direct user action, though, so call it from a click handler, not from a watcher or a lifecycle hook.
Redirect leaves the page and comes back. It avoids popup blockers and works in browsers with strict third-party storage rules, but your app reloads on return and loses any state you didn't persist.
Pick one per app. Mixing them leads to interaction_in_progress errors (see the FAQ).
Reading auth state
import { useIsAuthenticated } from "vue3-msal-plugin";
const isAuthenticated = useIsAuthenticated();
useIsAuthenticated() returns a Ref<boolean> that you can use straight in a template. The plugin listens to MSAL's events (sign-in, sign-out, silent SSO, redirect completion, token acquisition) and only updates accounts when the list actually changes, so it doesn't trigger extra re-renders.
For the user's details, read the account.
src/components/UserBadge.vue
<script setup lang="ts">
import { computed } from "vue";
import { useMsal } from "vue3-msal-plugin";
const { accounts } = useMsal();
const account = computed(() => accounts.value[0] ?? null);
</script>
<template>
<p v-if="account">{{ account.name }} ({{ account.username }})</p>
</template>
name and username come from the ID token. Use them for display, not for authorization. Access control belongs in the API that validates the access token.
Getting an access token
To call a protected API, you need an access token for its scopes. useMsalAuthentication handles that.
src/components/TokenDemo.vue
<script setup lang="ts">
import { watch } from "vue";
import { useMsalAuthentication, InteractionType } from "vue3-msal-plugin";
const { result, error, inProgress, acquireToken } = useMsalAuthentication(
InteractionType.Popup,
{ scopes: ["User.Read"] },
);
watch(result, (value) => {
if (value) {
console.log(value.accessToken);
}
});
</script>
It starts as soon as it's called. It tries acquireTokenSilent first, which returns a cached token or refreshes it in the background, and falls back to an interactive prompt if that fails. That's the pattern Microsoft recommends. You get result, error, and inProgress as refs, plus acquireToken to run it again.
Pass InteractionType.Popup or InteractionType.Redirect, not Silent. Only those two can fall back to a prompt, which a user needs the first time they consent to a new scope.
Request only the scopes of the API you're calling. For a second API, pass its scopes to acquireToken instead of widening the first request.
await acquireToken({ scopes: ["api://your-api-id/Files.Read"] });
MSAL caches tokens per scope, so acquiring one before every request is fine. Don't copy the token into a Pinia store or a cookie. Let MSAL handle expiry and refresh.
Calling Microsoft Graph
useMsal() also gives you callMsGraph, which fetches the signed-in user's profile.
src/components/Profile.vue
<script setup lang="ts">
import { ref, watch } from "vue";
import {
useMsal,
useMsalAuthentication,
InteractionType,
} from "vue3-msal-plugin";
const { callMsGraph } = useMsal();
const { result } = useMsalAuthentication(InteractionType.Popup, {
scopes: ["User.Read"],
});
const profile = ref<Record<string, unknown> | null>(null);
watch(result, async (value) => {
if (!value) return;
try {
profile.value = await callMsGraph(value.accessToken);
} catch (e) {
console.error(e);
}
});
</script>
<template>
<pre v-if="profile">{{ profile }}</pre>
</template>
callMsGraph only calls https://graph.microsoft.com/v1.0/me. For any other Graph endpoint, use the access token with fetch or the Graph SDK.
Protecting routes
A guard that only checks getAllAccounts() breaks redirect sign-ins. When the user comes back from Microsoft, the guard runs before MSAL has processed the response in the URL, finds no accounts, and sends a signed-in user to the home page. Await MSAL's startup steps inside the guard instead.
src/router/guards.ts
import type { PublicClientApplication } from "@azure/msal-browser";
import type { Router } from "vue-router";
export function registerGuard(
router: Router,
instance: PublicClientApplication,
) {
router.beforeEach(async (to) => {
if (!to.meta.requiresAuth) return true;
// Both are safe to call repeatedly; MSAL no-ops after the first run.
await instance.initialize();
await instance.handleRedirectPromise();
if (instance.getAllAccounts().length > 0) return true;
await instance.loginRedirect({
scopes: ["User.Read"],
redirectStartPage: to.fullPath,
});
return false;
});
}
redirectStartPage sends the user back to the page they asked for. The guard takes the instance as an argument because useMsal() only works inside components, not navigation guards.
Mark the routes that need it.
src/router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import Home from "../views/Home.vue";
import Profile from "../views/Profile.vue";
export default createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: Home },
{ path: "/profile", component: Profile, meta: { requiresAuth: true } },
],
});
Then call registerGuard(router, instance) in bootstrap(), after initialize() and before app.use(router). Installing the router starts the first navigation, so a guard registered later misses the first page load.
Nuxt
@azure/msal-browser needs window and localStorage, which don't exist during server-side rendering. The Nuxt sample splits the setup into a client plugin and a server plugin, so the composables work on both sides.
The client plugin is the same setup as main.ts.
app/plugins/msal.client.ts
import { msalPlugin, msalInstance } from "vue3-msal-plugin";
import type { AuthenticationResult, Configuration } from "@azure/msal-browser";
import { EventType } from "@azure/msal-browser";
export default defineNuxtPlugin(async (nuxtApp) => {
const config = useRuntimeConfig();
const msalConfig: Configuration = {
auth: {
clientId: String(config.public.clientId),
authority: String(config.public.authority),
redirectUri: String(config.public.redirectUri),
postLogoutRedirectUri: String(config.public.postLogoutRedirectUri),
},
cache: { cacheLocation: "localStorage" },
};
const instance = msalInstance(msalConfig);
await instance.initialize();
const accounts = instance.getAllAccounts();
if (accounts[0]) instance.setActiveAccount(accounts[0]);
instance.addEventCallback((event) => {
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
const payload = event.payload as AuthenticationResult;
instance.setActiveAccount(payload.account);
}
});
nuxtApp.vueApp.use(msalPlugin, instance);
});
The server plugin, msal.server.ts, gives $msal a stub with an empty account list, so useMsal() and useIsAuthenticated() work during SSR and the page renders signed out. The auth middleware skips the server, which can't see the localStorage cache, and checks the accounts after hydration. Both files are in the sample.
The trade-off is that the server always renders the signed-out state, so a signed-in user sees it briefly before the page hydrates. Avoiding that means sharing auth state with the server, usually through an httpOnly cookie that server middleware reads.
Trade-offs
For a single sign-in button, @azure/msal-browser on its own is enough. The plugin is worth adding once your templates need to react to auth state. It gives you the reactive account list, the interaction status, a signed-in flag, and the silent-then-interactive token flow, which you'd otherwise wire up yourself.
It doesn't hide MSAL. instance is the real PublicClientApplication, the config is MSAL's own Configuration type, and every MSAL method is available, so the MSAL docs still apply. The flip side is that MSAL's breaking changes reach your code too.
It isn't a security layer. It doesn't validate tokens, so your API still has to. callMsGraph only covers /me, and there's no server-side token handling yet, which is why the Nuxt setup renders signed out first. A Nuxt module that handles that is what I plan to build next.
It's MIT-licensed with one maintainer (me), so weigh it like any small dependency. There are working samples for Vue 3 with Pinia, Quasar, and Nuxt.
FAQs
What is vue3-msal-plugin?
vue3-msal-plugin is an open-source (MIT) Vue 3 plugin that wraps Microsoft's @azure/msal-browser library in composables: useMsal, useIsAuthenticated, and useMsalAuthentication. It gives Vue components reactive sign-in state and access tokens for Microsoft Entra ID (formerly Azure AD), and it ships TypeScript types. It's published on npm, and I maintain it.
How do I add Microsoft Entra ID sign-in to a Vue 3 app?
Register the app in Entra ID as a single-page application, install vue3-msal-plugin and @azure/msal-browser, create the MSAL instance, and await instance.initialize() before passing it to app.use(msalPlugin, instance). Then call instance.loginPopup(loginRequest) from a click handler, with instance and loginRequest from useMsal(). The registration step is where most setups go wrong.
Which versions of Vue and MSAL Browser does vue3-msal-plugin support?
Vue 3.5 or newer, @azure/msal-browser 4.x, and Node.js 20 or newer. Those are the plugin's declared ranges: vue@^3.5.0 and @azure/msal-browser@^4.0.0 as peer dependencies, and >=20 in engines.node. @azure/msal-browser 5 is outside the peer range.
Does vue3-msal-plugin support Vue 2?
No. vue3-msal-plugin requires Vue 3.5 or newer and is built on the Composition API. A Vue 2 app has to call @azure/msal-browser directly or use a different wrapper.
Does vue3-msal-plugin work with Nuxt?
Yes. Use a client plugin that sets up MSAL and a server plugin that gives SSR an empty account list, as in the Nuxt sample. The server renders the signed-out state until the page hydrates, because it can't read the browser's localStorage. The Nuxt section above has the details.
Is vue3-msal-plugin an official Microsoft library?
No, it's a community plugin. Microsoft publishes MSAL wrappers for React (@azure/msal-react) and Angular (@azure/msal-angular) but none for Vue. vue3-msal-plugin doesn't replace @azure/msal-browser: it depends on it, and useMsal().instance is the real PublicClientApplication, so every MSAL method and the MSAL docs still apply.
Should I use popup or redirect sign-in with MSAL in Vue?
Use popup for most single-page apps, because the app stays mounted and keeps its state. Use redirect when popups are blocked, or in browsers with strict third-party storage rules. Call popup only from a user action such as a click, and don't mix the two flows in one app.
Why do I get uninitialized_public_client_application?
uninitialized_public_client_application?Something called an MSAL API before initialize() resolved. @azure/msal-browser has required it since v3, and in v4 the localStorage cache only loads during initialize(), which is why tutorials written for v2 don't mention it. Await instance.initialize() before any other MSAL call, whether that's in main.ts, a Nuxt plugin, or a router guard.
What causes interaction_in_progress?
interaction_in_progress?Two interactive calls overlapping, or an earlier one that never finished and left its state behind. Check the inProgress ref from useMsal() before starting a new sign-in, make sure the redirect promise from an earlier attempt was handled, and don't mix popup and redirect in the same app.
How do I protect routes with Vue Router and MSAL?
Mark the routes with meta: { requiresAuth: true }, then in a router.beforeEach guard await instance.initialize() and instance.handleRedirectPromise() before checking getAllAccounts(). If there are no accounts, call loginRedirect with redirectStartPage set to the requested route. Without those awaits, the guard runs before MSAL has processed the redirect response and sends signed-in users back to the home page. See Protecting routes for the code.
Is localStorage safe for MSAL tokens?
localStorage safe for MSAL tokens?MSAL's localStorage cache keeps users signed in across tabs and reloads, but any script on your origin can read it, so an XSS bug could expose tokens. That's one reason access tokens are short-lived and scoped. Keep third-party scripts under control, set a strict Content Security Policy, and validate every token on the server. sessionStorage reduces the exposure, but the session no longer carries across tabs.