Integrating Microsoft Entra ID Auth in a Next.js and NestJS App
One of the more useful authentication setups I implemented recently was Microsoft Entra ID auth for a full stack application built with Next.js and NestJS. The goal was not just to let users sign in with Microsoft, but to make the login flow production-ready: validate the access token on the backend, check Azure Security Group membership, sync users into the local database, and then issue our own JWT for the application session. That gave us a clean separation between Microsoft authentication and our own application authorization flow.
Start with Azure App Registration
The first step is setting up the application in Azure through App Registrations. That is where the application identity is created and where the important values come from: client ID, tenant ID, and client secret.
At this stage I also configure Redirect URIs for every environment. I usually add one for local development and one for each deployed environment. In practice that means local login can return to http://localhost:3000, while QA and production each get their own redirect URL. This is important because the frontend login flow needs to land back on a valid registered callback after Microsoft completes authentication.
Configure MSAL in the Next.js app
On the frontend side I use MSAL to handle the Microsoft login flow. Once the Azure application is ready, I configure MSAL in the Next.js app using the tenant ID and client ID, and define the redirect URI that matches the current environment.
After that, the login component only needs to trigger the configured MSAL login method. Once the user signs in successfully, the frontend receives the Microsoft access token. That token is then sent to the NestJS backend, where the more important validation and authorization steps happen.
import { Configuration, PublicClientApplication } from "@azure/msal-browser";export const msalConfig: Configuration = {auth: {clientId: `${process.env.NEXT_PUBLIC_MS_CLIENT_ID}`,authority: `https://login.microsoftonline.com/${process.env.NEXT_PUBLIC_MS_TENANT_ID}`,redirectUri: `${process.env.NEXT_PUBLIC_MS_REDIRECT_URI}`,},cache: {cacheLocation: "localStorage",},};export const loginRequest = {scopes: ["openid","profile","email","User.Read",],};export const msalInstance = new PublicClientApplication(msalConfig);
const login = async () => {try {const loginResult = await msalInstance.loginPopup(loginRequest);const account = loginResult.account!;msalInstance.setActiveAccount(account);const tokenResult = await msalInstance.acquireTokenSilent({...loginRequest,account,});// send tokenResult.accessToken to backend// persist backend user and JWT in local storage} catch (error) {console.error("Login error:", error);}};
Validate the Microsoft access token on the backend
I do not treat the Microsoft access token as trusted just because it came from the frontend. In NestJS, the first backend step is validating that token against Microsoft's signing keys.
For that, I use the key discovery endpoint for the tenant and verify that the token is valid before doing anything else. This step is critical because everything after that depends on the backend being sure the token really came from Microsoft and really belongs to the authenticated user.
https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys
Check Azure Security Group membership
After token validation, the next step in my flow is authorization through Azure Security Groups. The idea is simple: not every valid Microsoft user should automatically get access to the app. Only users who belong to one of the allowed Azure Security Groups should be let in.
To do that, I fetch the groups the user belongs to and compare them against a list of allowed group IDs stored in our environment configuration. This makes group-based access control easy to manage without hardcoding access logic into the frontend.
fetch("https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=id&$top=999", {headers: {Authorization: `Bearer ${accessToken}`,},});
Fetch the Microsoft user profile and sync the local user
Once the user is both authenticated and authorized, I fetch the basic user profile from Microsoft Graph. In my flow that includes fields such as id, mail, displayName, and userPrincipalName.
That data is then matched against the local user table. If the user does not exist yet, I create a new record. If the user already exists, I compare the important fields and update them if something changed. This keeps the local application user record in sync with Microsoft without forcing me to treat Microsoft Graph as the only source of user state inside the app.
fetch("https://graph.microsoft.com/v1.0/me?$select=id,mail,displayName,userPrincipalName", {headers: {Authorization: `Bearer ${accessToken}`,},});
Issue your own JWT for the application session
After the Microsoft token is validated, the user is authorized by group membership, and the local user is synchronized, I issue our own JWT from the NestJS backend and return user and session data to the frontend.
This is an important design choice. It means the app can rely on its own session token for protected API routes instead of using the raw Microsoft token everywhere. That gives more control over authorization, simplifies backend guards, and makes the application architecture cleaner.
Load extra user details on the frontend
After the frontend receives the successful login response and the local JWT flow is established, I do one more thing in the Next.js app: fetch the Microsoft user profile and photo for display purposes.
That lets me show user information in the UI without depending only on what came back in the first login response. For the user photo, I request the Microsoft Graph photo endpoint, convert the blob to a base64 data URL, and persist it so it survives page refreshes more cleanly.
export async function fetchUserProfile() {const token = await getAccessToken();const res = await fetch(`${GRAPH_BASE_ULR}/me`, {headers: {Authorization: `Bearer ${token}`,},});return res.json();}export async function fetchUserPhoto(): Promise<string | null> {try {const token = await getAccessToken();const res = await fetch(`${GRAPH_BASE_ULR}/me/photo/$value`, {headers: {Authorization: `Bearer ${token}`,},});if (!res.ok) return null;const blob = await res.blob();return new Promise((resolve) => {const reader = new FileReader();reader.onloadend = () => resolve(reader.result as string);reader.onerror = () => resolve(null);reader.readAsDataURL(blob);});} catch {return null;}}
const loadUserProfile = async () => {const profile = await fetchUserProfile();const photoUrl = await fetchUserPhoto();const initials =profile.givenName && profile.surname? `${profile.givenName[0]}${profile.surname[0]}`: profile.displayName.split(" ").map((n: string) => n[0]).slice(0, 2).join("");persistUser({displayName: profile.displayName,email: profile.userPrincipalName,initials,photoUrl,});};
const logout = async () => {try {setJwt(null);clearPersistedUser();clearBackendUser();api.removeAuthToken();localStorage.removeItem(JWT_TOKEN);localStorage.removeItem(REDIRECT_URL);localStorage.removeItem("backend_user");await msalInstance.logoutPopup({postLogoutRedirectUri: window.location.origin,mainWindowRedirectUri: window.location.origin,});} catch (error) {console.error("Logout error:", error);window.location.href = "/";}};
Why this flow works well in real projects
What I like about this setup is that it keeps each responsibility in the right place. Microsoft Entra ID handles identity, MSAL handles the login flow on the frontend, NestJS handles backend validation and authorization, Azure Security Groups control access, and the application still keeps its own local user and JWT session model.
That makes the system easier to reason about and much easier to extend later. If you need local roles, audit logging, user syncing, or environment-specific access control, this flow already gives you the right foundation.
Continue exploring
More articles and full stack work
If this article was useful, you can also explore more writing on backend architecture, database-driven applications, and full stack product development.