Blog
VueAuthenticationAzureTypeScript

MSAL Authentication in Vue 3: A Practical Guide

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

Dulan Katukurundage Published
vue3-msal-plugin banner

Wiring Microsoft Entra ID into a Vue 3 app takes more code than you'd expect. @azure/msal-browser hands you a PublicClientApplication and a set of promises. What it doesn't hand you is anything reactive.

So you write the same layer in every project: an init sequence that has to finish before any other MSAL call, an event listener that keeps the active account in sync, and a wrapper that turns the account list into something a template can render without going stale.

I wrote vue3-msal-plugin because I was rewriting that layer every time. It wraps @azure/msal-browser in Vue composables and leaves the MSAL API alone underneath.

By the end of this guide you'll have a Vue 3 app that signs users in against Entra ID, holds a reactive auth state, acquires access tokens, calls Microsoft Graph, and blocks unauthenticated users at the router. There is a section on Nuxt at the end, because server-side rendering changes the answer.

What you need first

  • Node.js 20 or newer
  • Vue 3.5 or newer
  • @azure/msal-browser 4 or newer
  • A Microsoft Entra ID tenant you can register an application in

Those are hard requirements. vue3-msal-plugin declares vue@^3.5.0 and @azure/msal-browser@^4.0.0 as peer dependencies, and sets engines.node to >=20.

Registering the app in Microsoft Entra ID

MSAL can't do anything until Entra ID knows your app exists. Most Vue MSAL guides assume you already have a configured tenant and skip straight to the code, so this section covers the portal side.

Open the Microsoft Entra admin center, then go to Identity → Applications → App registrations → New registration.

Three fields matter:

  1. Name. Internal label only. Users never see it.
  2. Supported account types. Pick Accounts in this organizational directory only for a single-tenant app. Multi-tenant and personal-account options change your authority URL later.
  3. Redirect URI. Choose the Single-page application platform, and enter http://localhost:5173 for a default Vite dev server.

The platform choice is the one that silently breaks things. Selecting Web instead of Single-page application registers your app for a flow that expects a client secret. A browser app has nowhere safe to keep a secret, so MSAL uses authorization code flow with PKCE instead, and that only works against a SPA-platform redirect URI. If sign-in fails with a cross-origin or unsupported-flow error, check this first.

Leave the implicit grant checkboxes alone. Implicit flow is a legacy option that MSAL v2 and later don't need.

From the Overview page, copy two values:

  • Application (client) ID
  • Directory (tenant) ID

Then go to API permissions. User.Read under Microsoft Graph delegated permissions is usually granted by default. You need it for the Graph section below.

Put both IDs in a .env file:

VITE_CLIENT_ID="00000000-0000-0000-0000-000000000000"
VITE_AUTHORITY="https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111"

The authority is https://login.microsoftonline.com/ plus your tenant ID for a single-tenant app. Multi-tenant apps use /organizations, and apps accepting personal Microsoft accounts use /common.

Every redirect URI your app uses in production has to be registered too, including the post-logout one. Entra ID rejects any URI it hasn't seen, and the error arrives at the Microsoft sign-in page rather than in your console.

Installing and registering the plugin

npm i vue3-msal-plugin @azure/msal-browser

The plugin needs an MSAL instance, not a config object. You build the instance, set the active account, subscribe to login events, then hand it to Vue:

// 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",
  },
};

const instance = msalInstance(msalConfig);

// 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");

Two things in there are easy to leave out and annoying to debug.

setActiveAccount after getAllAccounts is what survives a page refresh. MSAL keeps the account in the cache, but it doesn't pick an active one for you, so token calls fail on reload with an account-missing error even though the user is signed in.

The LOGIN_SUCCESS callback covers the other direction. A first-time sign-in populates the cache but leaves the active account unset until you set it.

cacheLocation: 'localStorage' keeps the session across tabs and reloads. The default is sessionStorage, which doesn't.

Once the plugin is installed, useMsal() handles the rest of the startup sequence. On first call, while the interaction status is still Startup, it calls initialize() and then handleRedirectPromise(). That matters because @azure/msal-browser has required an awaited initialize() before any other API call since v3. Skip it and you get:

BrowserAuthError: uninitialized_public_client_application

Signing in: popup or redirect

useMsal() gives you the MSAL instance plus reactive state:

const { instance, accounts, inProgress, loginRequest, callMsGraph } = useMsal();

instance is the plain PublicClientApplication. accounts and inProgress are refs. loginRequest is a default request object of { scopes: ['User.Read'] }, which is enough to sign in and read the user's own profile.

<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 keeps your app mounted and its state intact. That makes it the better default for anything holding unsaved form state. The cost: browsers block popups that aren't triggered by a direct user gesture, so it has to run from a click handler, never from a watcher or lifecycle hook.

Redirect navigates away and comes back. It survives popup blockers and works on browsers with strict third-party storage rules, which is why embedded and mobile scenarios tend to need it. The cost is that your app remounts on return, and any state you didn't persist is gone.

Pick one and use it consistently. Mixing them across an app produces interaction-status bugs that are hard to trace.

Reading auth state in a component

import { useIsAuthenticated } from "vue3-msal-plugin";

const isAuthenticated = useIsAuthenticated();

It returns a Ref<boolean> derived from the account list, so a template can use it directly and it stays current when accounts change. The plugin subscribes to MSAL's event stream and refreshes accounts on login, logout, silent SSO, redirect completion, and token acquisition. It diffs the account arrays before writing, so an unchanged account list won't trigger a re-render.

For the signed-in user's details, read the account:

<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>

account.name and account.username come from the ID token, so they are fine for display. Never use them for authorization. Access control belongs on the API that validates the access token, not in your Vue app.

Getting an access token

Calling any protected API means acquiring an access token for its scopes. useMsalAuthentication wraps that:

<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 acquiring as soon as it's called, then re-tries when the global interaction status changes, and stops watching once it has a result or an error. You get four things back: acquireToken to trigger it manually, plus result, error, and inProgress as refs.

The sequence it runs is the pattern Microsoft recommends. Try acquireTokenSilent first, which returns a cached access token or refreshes it in the background. If that fails, fall back to an interactive prompt.

Pass InteractionType.Popup or InteractionType.Redirect rather than Silent. Those two have an interactive fallback when the silent call fails, which is what you want the first time a user has to consent to a new scope. Silent gives you no fallback path.

Request the scopes for the API you are actually calling. A token for User.Read won't work against your own API, and asking for every scope up front produces a consent screen that makes users hesitate. If you need a second API later, call acquireToken with an override instead of widening the original request:

await acquireToken({ scopes: ["api://your-api-id/Files.Read"] });

Tokens are cached by MSAL, keyed by scope. Calling acquireTokenSilent before every request is the correct pattern, not a performance problem. Don't stash the access token in a Pinia store or a cookie and reuse it. Let MSAL own expiry and refresh.

Calling Microsoft Graph

useMsal() exposes a callMsGraph helper for the common case of reading the signed-in user's profile:

<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 issues a GET against https://graph.microsoft.com/v1.0/me with the token as a bearer header. That endpoint is hardcoded, so treat it as a /me shortcut rather than a Graph client. For any other Graph resource, use the access token with fetch or the Graph SDK directly. The token is the part that was hard.

Protecting routes with Vue Router

The obvious guard is wrong in one specific way:

// Broken on redirect returns.
router.beforeEach((to) => {
  if (to.meta.requiresAuth && instance.getAllAccounts().length === 0) {
    return "/";
  }
});

On a redirect return, the app remounts and the router guard runs before MSAL has processed the response in the URL. The account cache is still empty, so the guard bounces an authenticated user back to the home page. Await the MSAL startup sequence 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 is what returns the user to the page they asked for instead of your app root.

Mark the routes that need it and register the guard with the same instance you passed to the plugin:

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: "/", component: Home },
    { path: "/profile", component: Profile, meta: { requiresAuth: true } },
  ],
});

registerGuard(router, instance);

useMsal() can't run here. It reads the component instance from getCurrentInstance(), which is null inside a navigation guard. Pass the MSAL instance in explicitly.

Using MSAL in Nuxt

@azure/msal-browser reads window and localStorage. Neither exists during server-side rendering, so a single shared plugin either crashes on the server or forces you to wrap half your UI in <ClientOnly>.

The Nuxt sample in the repo uses two plugins instead, so $msal resolves on both sides.

The client plugin builds and installs the instance:

// app/plugins/msal.client.ts
import { msalPlugin, msalInstance } from "vue3-msal-plugin";
import type { Configuration } 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),
    },
    cache: { cacheLocation: "localStorage" },
  };

  const instance = msalInstance(msalConfig);
  await instance.initialize();

  const accounts = instance.getAllAccounts();
  if (accounts[0]) instance.setActiveAccount(accounts[0]);

  nuxtApp.vueApp.use(msalPlugin, instance);
});

This awaits initialize() explicitly, before the plugin is installed, so no component ever sees an uninitialized instance.

The server plugin installs a stub with an empty account list, so useMsal() and useIsAuthenticated() resolve during SSR instead of throwing. Any method that needs the browser instance rejects with a clear message, and those calls only happen after hydration:

// app/plugins/msal.server.ts
import { shallowReactive } from "vue";
import { InteractionStatus } from "@azure/msal-browser";
import type { AccountInfo, PublicClientApplication } from "@azure/msal-browser";
import type { State } from "vue3-msal-plugin";

export default defineNuxtPlugin((nuxtApp) => {
  const ssrInstance = new Proxy({} as PublicClientApplication, {
    get(_target, prop) {
      if (prop === "getAllAccounts") return (): AccountInfo[] => [];
      if (prop === "getActiveAccount") return (): AccountInfo | null => null;
      return () =>
        Promise.reject(
          new Error(`MSAL "${String(prop)}" not available during SSR`),
        );
    },
  }) as PublicClientApplication;

  nuxtApp.vueApp.config.globalProperties.$msal = shallowReactive<State>({
    instance: ssrInstance,
    inProgress: InteractionStatus.None,
    accounts: [],
  });
});

Route middleware has the same constraint as a Vue Router guard, plus one more: the server can't know the auth state at all, because the cache lives in localStorage. So the guard skips on the server and enforces after hydration:

// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
  if (import.meta.server) return;

  const nuxtApp = useNuxtApp();
  const accounts = nuxtApp.vueApp.config.globalProperties.$msal?.accounts;
  if (!accounts || accounts.length === 0) {
    return navigateTo("/");
  }
});

This pattern has one visible consequence. The server always renders a signed-out shell, so on first paint the user looks signed out, then the client state hydrates and the UI flips. That flash is inherent to a browser-only token cache. Removing it means bridging auth state to the server, usually with an httpOnly cookie that Nuxt server middleware reads to seed the SSR account list.

What this plugin does not do

It's a reactivity layer, not a security boundary. It doesn't validate tokens, and it can't: that's your API's job, and a signed-in Vue app proves nothing to a server that hasn't checked the token itself. It tracks @azure/msal-browser v4 closely rather than abstracting it, so MSAL's breaking changes reach your code, and its config object is MSAL's config object. callMsGraph covers the /me endpoint and nothing else. There's no server-side token handling today, which is why the Nuxt sample accepts the hydration flash described above. A dedicated Nuxt module is what I plan to build next, and owning that cookie-to-SSR bridge is the reason it needs to exist. It's also a small MIT-licensed project with one maintainer, so weigh that the way you'd weigh any dependency.

Why not use msal-browser directly

You can, and for a single sign-in button you probably should. What the plugin removes is the reactive plumbing between MSAL's event stream and your templates.

What you needWith @azure/msal-browser aloneWith the plugin
Init before the first callAwait initialize() yourself before mountuseMsal() runs it on first use
Redirect return handlingCall and catch handleRedirectPromise()Runs with initialize()
Reactive account listYour own ref, event callback, and array diffaccounts ref
Interaction statusMap events via EventMessageUtils yourselfinProgress ref
Signed-in booleanDerive it and keep it in syncuseIsAuthenticated()
Silent-then-interactive tokensTry silent, catch, branch on interaction typeuseMsalAuthentication()
Graph /me requestWrite the bearer-header fetchcallMsGraph(token)

The trade is the usual one. A wrapper that hides its dependency saves you typing until the day the behavior underneath surprises you, and then you're debugging two libraries. This one doesn't hide MSAL. instance is the PublicClientApplication itself, the config is MSAL's Configuration type, and every MSAL method stays reachable. So when something odd happens, the MSAL docs still apply.

There are working versions of everything above in the repo: Vue 3 with Pinia, Quasar, and Nuxt.

FAQs

Why do I get uninitialized_public_client_application?

Something called an MSAL API before initialize() resolved. It has been required since @azure/msal-browser v3, which is why tutorials written for v2 don't mention it. useMsal() handles it in components. In a router guard, Nuxt plugin, or any code running before the first component setup, await instance.initialize() yourself.

Should I use popup or redirect?

Popup for most SPAs, because your app stays mounted and keeps its state. Redirect when popups are blocked, or on browsers with strict third-party storage restrictions. Call popup only from a user gesture, and don't mix the two flows in one app.

What causes interaction_in_progress?

Two overlapping interactive calls, or a previous one that never finished and left its state behind. Guard on the inProgress ref from useMsal() before starting a new interaction, and make sure the redirect promise from an earlier attempt was handled.

Is localStorage safe for tokens?

It persists across tabs and reloads, which is why the samples use it, and it's readable by any script on your origin. That's an XSS exposure and the reason access tokens are short-lived and scoped. Keep third-party scripts under control, set a strict CSP, and validate every token server-side. sessionStorage narrows the window at the cost of the session.

Does it work with Nuxt?

Yes, with the client and server plugin pair shown above, and with the caveat that SSR renders a signed-out shell until hydration. A Nuxt module that bridges auth state to the server is planned.

Does it support Vue 2?

No. It requires Vue 3.5 or newer and is built on the Composition API.