← Back to blogs
AuthenticationApril 20269 min read

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.

Create the app registration in Azure / Microsoft Entra ID
Collect client ID, tenant ID, and client secret
Set Redirect URIs for local, QA, and production environments
Keep environment-specific values in configuration, not in code

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.

Set up MSAL config with tenant ID, client ID, and redirect URI
Use environment-based redirect URLs
Trigger login through the configured MSAL method
Pass the Microsoft access token to the backend after login
MSAL config
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);
Login flow
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.

Use the tenant key discovery endpoint
Validate the token signature and claims on the backend
Do not skip backend validation even if MSAL login succeeded on the frontend
Microsoft signing keys endpoint
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 the user's transitive group membership from Microsoft Graph
Compare returned group IDs with allowed IDs from environment variables
Reject access if the user does not belong to an allowed security group
Group membership request
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 user profile from Microsoft Graph
Create a local user if one does not exist
Update local user data if profile values changed
Keep Microsoft identity and local application user records aligned
Profile request
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.

Return local user and session data from NestJS
Issue your own application JWT after Microsoft validation
Use the app JWT for your own protected backend routes

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.

Fetch /me for profile data
Fetch /me/photo/$value for the user image
Convert the photo blob into a base64 data URL
Persist display name, email, initials, and photo on the frontend
Fetch profile and photo
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;
}
}
Persist profile on the frontend
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,
});
};
Logout flow
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.