Every enterprise MEAN engagement reaches the same meeting: the client's IT team says "it has to use our single sign-on." That usually means Microsoft Entra ID, sometimes Okta, sometimes a self-hosted Keycloak. What it always means is that the email-and-password login and the localStorage JWT you built in month one have to go.
This tutorial shows the pattern we deploy: OpenID Connect Authorization Code flow with PKCE, terminated on the Express 5 server as a backend-for-frontend (BFF), a cookie-based session for the Angular 22 client, group-to-role mapping into MongoDB, and role guards on both layers. Versions are Node 24 LTS, Express 5, Mongoose 8 on MongoDB 8, and Angular 22.
Why the BFF pattern and not a token in the browser
The tutorials you will find mostly do OIDC in the SPA with angular-auth-oidc-client or MSAL, then send the access token to the API. That works, and for some architectures it is correct. For most enterprise line-of-business MEAN apps we prefer the BFF:
- No tokens in JavaScript. Access and refresh tokens stay server-side. An XSS bug can call your API as the user, but it cannot exfiltrate a refresh token and replay it for weeks.
- Refresh is invisible. The server silently refreshes with the refresh token; the browser only ever holds an
HttpOnlycookie. - One place to revoke. Deleting the server session logs the user out immediately, which a stateless JWT cannot do.
- The IT questionnaire gets easier. "Tokens are never exposed to the browser" answers half of a security review.
The trade-off: your API becomes stateful (a session store) and cross-origin cookie handling needs care. Both are manageable, as below.
1. Register the application with the identity provider
Entra ID: App registrations → New registration → Web platform, redirect URI https://app.example.com/auth/callback. Create a client secret. Note the tenant ID and client ID. Under Token configuration add the optional groups claim (or use App Roles, which are cleaner — see the roles section). Discovery document:
https://login.microsoftonline.com/<TENANT_ID>/v2.0/.well-known/openid-configuration
Keycloak: create a confidential client with Standard Flow enabled, the same redirect URI, and a groups or roles client scope mapper. Discovery document:
https://kc.example.com/realms/<REALM>/.well-known/openid-configuration
Put the values in the environment; never in the repo:
OIDC_ISSUER=https://login.microsoftonline.com/<TENANT_ID>/v2.0
OIDC_CLIENT_ID=...
OIDC_CLIENT_SECRET=...
OIDC_REDIRECT_URI=https://app.example.com/auth/callback
SESSION_SECRET=<32+ random bytes>
MONGODB_URI=mongodb+srv://...
2. Express 5 dependencies
We use openid-client v6, which is a certified OIDC relying-party implementation and does discovery, PKCE, state/nonce, and token refresh for you. Do not hand-roll this.
npm install express@5 openid-client@6 express-session connect-mongo mongoose@8 helmet zod
api/src/oidc.js — discovery once at boot, then helpers:
import * as client from 'openid-client';
let config;
export async function initOidc() {
config = await client.discovery(
new URL(process.env.OIDC_ISSUER),
process.env.OIDC_CLIENT_ID,
process.env.OIDC_CLIENT_SECRET,
);
return config;
}
export const oidcConfig = () => config;
export const redirectUri = () => process.env.OIDC_REDIRECT_URI;
3. Session middleware
import session from 'express-session';
import MongoStore from 'connect-mongo';
export const sessions = session({
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true,
store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI, ttl: 8 * 60 * 60 }),
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 8 * 60 * 60 * 1000,
},
});
sameSite: 'lax' is correct when the Angular app and the API are served from the same site (the deployment we recommend: ng build output served by the same Express 5 process, as in our modern MEAN app tutorial). If the SPA lives on a different origin you need sameSite: 'none'; secure: true, cors({ origin: <spa>, credentials: true }), and withCredentials on the client — and you have to defend CSRF explicitly.
4. The three OIDC routes
import * as client from 'openid-client';
import { oidcConfig, redirectUri } from './oidc.js';
import { upsertUserFromClaims } from './users.js';
// Kick off login
app.get('/auth/login', async (req, res) => {
const config = oidcConfig();
const code_verifier = client.randomPKCECodeVerifier();
const code_challenge = await client.calculatePKCECodeChallenge(code_verifier);
const state = client.randomState();
const nonce = client.randomNonce();
req.session.oidc = { code_verifier, state, nonce, returnTo: req.query.returnTo ?? '/' };
const url = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri(),
scope: 'openid profile email offline_access',
code_challenge,
code_challenge_method: 'S256',
state,
nonce,
});
res.redirect(url.href);
});
// Handle the provider's redirect
app.get('/auth/callback', async (req, res) => {
const pending = req.session.oidc;
if (!pending) return res.status(400).send('no login in progress');
const tokens = await client.authorizationCodeGrant(
oidcConfig(),
new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`),
{
pkceCodeVerifier: pending.code_verifier,
expectedState: pending.state,
expectedNonce: pending.nonce,
idTokenExpected: true,
},
);
const claims = tokens.claims();
const user = await upsertUserFromClaims(claims);
// New session id on privilege change: prevents session fixation.
await new Promise((resolve, reject) =>
req.session.regenerate((err) => (err ? reject(err) : resolve())));
req.session.user = { id: user.id, email: user.email, name: user.name, roles: user.roles };
req.session.tokens = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + (tokens.expires_in ?? 300) * 1000,
};
res.redirect(pending.returnTo);
});
// Logout locally, then at the provider (RP-initiated logout)
app.post('/auth/logout', (req, res) => {
const idTokenHint = req.session.tokens?.id_token;
req.session.destroy(() => {
const config = oidcConfig();
const end = config.serverMetadata().end_session_endpoint;
if (!end) return res.json({ redirect: '/' });
const url = client.buildEndSessionUrl(config, {
post_logout_redirect_uri: process.env.APP_BASE_URL,
id_token_hint: idTokenHint,
});
res.json({ redirect: url.href });
});
});
Two details that cause real bugs. state and nonce must come out of the session, not a cookie the client can rewrite — openid-client will throw if they do not match, which is exactly what you want. And req.session.regenerate after a successful login is the cheap fix for session fixation; skip it and a pre-seeded sid survives authentication.
Also note the callback route reads req.originalUrl. In Express 5, req.query uses the simple parser and is a read-only getter; building a URL from the raw request is the reliable way to hand the full callback URI to the library.
5. Mapping IdP groups to application roles
Never store authorization decisions only in the token. Map claims to your own roles at login, persist them, and re-read them per request.
// api/src/users.js
import { Schema, model } from 'mongoose';
const userSchema = new Schema({
sub: { type: String, required: true, unique: true, index: true },
issuer: { type: String, required: true },
email: { type: String, lowercase: true, trim: true, index: true },
name: String,
roles: { type: [String], default: [] },
lastLoginAt: Date,
}, { timestamps: true });
export const User = model('User', userSchema);
// Entra App Roles arrive in `roles`; Keycloak realm roles in `realm_access.roles`;
// Entra security groups arrive in `groups` as object ids.
const GROUP_TO_ROLE = new Map([
['1f6c2b1e-....-finance-group-id', 'finance'],
['approvers', 'approver'],
['app-admins', 'admin'],
]);
export function rolesFromClaims(claims) {
const raw = [
...(claims.roles ?? []),
...(claims.groups ?? []),
...(claims.realm_access?.roles ?? []),
];
const mapped = raw.map((g) => GROUP_TO_ROLE.get(g)).filter(Boolean);
return [...new Set(['user', ...mapped])];
}
export async function upsertUserFromClaims(claims) {
return User.findOneAndUpdate(
{ sub: claims.sub },
{
issuer: claims.iss,
email: claims.email ?? claims.preferred_username,
name: claims.name,
roles: rolesFromClaims(claims),
lastLoginAt: new Date(),
},
{ new: true, upsert: true, setDefaultsOnInsert: true },
);
}
Key the user on sub (stable, per-issuer), not on email. Emails change, and in some tenants they are reassigned. If you are migrating an existing password-based user collection, add sub and backfill it by matching email on first SSO login, then remove passwordHash once every user has signed in through the IdP.
A warning for Entra ID: if a user belongs to many groups, Microsoft omits the groups claim and returns a _claim_sources overage indication instead. Either use App Roles (our default — they are scoped to your app and do not overflow) or call Microsoft Graph for group membership at login.
6. Guards on the API
export function requireSession(req, res, next) {
if (!req.session?.user) return res.status(401).json({ error: 'unauthenticated' });
next();
}
export const requireRole = (...allowed) => (req, res, next) => {
const roles = req.session.user?.roles ?? [];
if (!allowed.some((r) => roles.includes(r))) {
return res.status(403).json({ error: 'forbidden', need: allowed });
}
next();
};
app.get('/api/me', requireSession, (req, res) => res.json(req.session.user));
app.get('/api/invoices', requireSession, requireRole('finance', 'admin'), async (req, res) => {
res.json(await Invoice.find({ tenant: req.session.user.tenant }).lean());
});
Every privileged route names its roles. Do not rely on the Angular guard — that is UX, not security.
Refreshing behind the scenes
If the API calls a downstream resource (Microsoft Graph, another service) with the access token, refresh it lazily:
export async function withFreshToken(req) {
const t = req.session.tokens;
if (!t?.refresh_token || Date.now() < t.expires_at - 60_000) return t?.access_token;
const refreshed = await client.refreshTokenGrant(oidcConfig(), t.refresh_token);
req.session.tokens = {
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token ?? t.refresh_token,
expires_at: Date.now() + (refreshed.expires_in ?? 300) * 1000,
};
return refreshed.access_token;
}
If the refresh grant fails (user disabled, consent revoked, password reset), destroy the session and return 401. The client will bounce the user back through /auth/login.
7. The Angular 22 side
The client has no OIDC library, no token handling, and no MSAL. It has one HTTP call and a redirect.
// src/app/session.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface SessionUser { id: string; email: string; name: string; roles: string[] }
@Injectable({ providedIn: 'root' })
export class Session {
private http = inject(HttpClient);
readonly user = signal<SessionUser | null>(null);
readonly loaded = signal(false);
readonly signedIn = computed(() => this.user() !== null);
has(...roles: string[]) {
const mine = this.user()?.roles ?? [];
return roles.some((r) => mine.includes(r));
}
async load() {
try {
this.user.set(await firstValueFrom(this.http.get<SessionUser>('/api/me')));
} catch {
this.user.set(null);
} finally {
this.loaded.set(true);
}
}
login(returnTo = location.pathname) {
location.assign(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
}
async logout() {
const { redirect } = await firstValueFrom(this.http.post<{ redirect: string }>('/auth/logout', {}));
this.user.set(null);
location.assign(redirect);
}
}
Load the session once at bootstrap so guards do not race:
// app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection, provideAppInitializer, inject } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { Session } from './session';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withFetch()),
provideAppInitializer(() => inject(Session).load()),
],
};
Functional route guards:
// src/app/guards.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Session } from './session';
export const authGuard: CanActivateFn = (_route, state) => {
const session = inject(Session);
if (session.signedIn()) return true;
session.login(state.url);
return false;
};
export const roleGuard = (...roles: string[]): CanActivateFn => () => {
const session = inject(Session);
if (session.has(...roles)) return true;
return inject(Router).createUrlTree(['/forbidden']);
};
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'invoices', loadComponent: () => import('./invoices').then(m => m.Invoices),
canActivate: [authGuard, roleGuard('finance', 'admin')] },
{ path: 'forbidden', component: Forbidden },
];
And a 401 interceptor so an expired session re-triggers login instead of showing a broken page:
export const authRedirectInterceptor: HttpInterceptorFn = (req, next) => {
const session = inject(Session);
return next(req).pipe(catchError((err) => {
if (err.status === 401 && !req.url.endsWith('/api/me')) session.login();
return throwError(() => err);
}));
};
Role-conditional UI uses the signal directly:
@if (session.has('admin')) {
<a routerLink="/admin">Administration</a>
}
8. Testing it
- Local IdP. Run Keycloak in Docker (
quay.io/keycloak/keycloak:26.0 start-dev), create a realm, and pointOIDC_ISSUERat it. Far faster than round-tripping a client's Entra tenant, and it exercises the same code paths. - API tests. Inject a fake session rather than mocking the IdP: in test mode, mount a
/test/loginroute guarded by an env flag that setsreq.session.user. Then testrequireRolebehaviour directly withnode --testor Vitest. - E2E. Playwright can drive the real Keycloak login form once in a setup project and reuse
storageState, exactly as in our Playwright tutorial. Cookie-based sessions are captured by storage state without extra work.
9. Production checklist
helmet()on, HTTPS only,trust proxyset if you terminate TLS at a load balancer (otherwisesecurecookies are never sent).- Session store in MongoDB or Redis with a TTL — not the default in-memory store, which leaks and breaks with more than one instance.
session.regenerateon login;session.destroyplus RP-initiated logout on sign-out.- Client secret in a secret manager, rotated; or better, workload identity federation so there is no secret at all.
- Validate every callback with
state,nonce, and PKCE — the library does it only if you pass the expected values. - Restrict redirect URIs to an exact allowlist at the IdP, and validate
returnTois a path on your own site before redirecting to it. - Log
sub,iss, session id and roles on every login; enterprises will ask for an audit trail. - Decide what happens when a user is removed from a group: session TTL is your revocation window, so keep it short (we use 8 hours with rolling renewal) or re-read roles from MongoDB on each request.
- Keep a break-glass local admin account, disabled by default, for the day the IdP is unreachable.
When we reach for the SPA-side flow instead
If the Angular app must call third-party APIs directly from the browser, or the client insists on MSAL because their security team has already certified it, do the standard SPA code flow with PKCE and treat the access token as short-lived and in-memory only. The Express 5 side then validates the JWT against the IdP's JWKS (jose's createRemoteJWKSet) with pinned algorithm, issuer, and audience checks. The role mapping in section 5 is unchanged; only where the token lives differs.
Enterprise SSO is where a working MEAN application meets a client's identity platform, and it is usually the last blocker before go-live. We do this work as part of Enterprise MEAN Stack App Development and MEAN Stack API Development engagements — including the parts that are not code, like getting the app registration approved. If you have an app that needs to speak Entra ID or Keycloak by a fixed date, get in touch.