Protect a DaloyJS API with Microsoft Entra ID (MSAL)
Microsoft Entra ID (formerly Azure Active Directory) is Microsoft's enterprise identity platform. For a backend API, the job is to verify the v2.0 access token in the Authorizationheader against the tenant's public JWKS. The jose library is the modern, runtime-portable choice for that. Use @azure/msal-node on top when your API needs to call another protected service (OAuth 2.0 on-behalf-of, client credentials, etc.).
1. Register the API in Entra ID
- In the Microsoft Entra admin center, go to Entra ID → App registrations → New registration and register your API app.
- Under Expose an API, set an Application ID URI (e.g.
api://my-daloy-api) and add one or more scopes (e.g.access_as_user). - Register your client app separately and grant it permission to the scope above. Note the tenant IDand the API app's Application (client) ID.
- The OIDC discovery document lives at
https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configurationand references the JWKS athttps://login.microsoftonline.com/{tenantId}/discovery/v2.0/keys.
2. Install
pnpm add joseAdd @azure/msal-node as well only if the API itself needs to acquire downstream tokens (see below).
3. Environment variables
# .env
ENTRA_TENANT_ID=11111111-2222-3333-4444-555555555555
ENTRA_API_AUDIENCE=api://my-daloy-api # or the API app's client ID GUID
ENTRA_REQUIRED_SCOPE=access_as_user4. Plugin
// src/plugins/entra.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import type { App } from "@daloyjs/core";
const tenantId = process.env.ENTRA_TENANT_ID!;
const issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`;
const audience = process.env.ENTRA_API_AUDIENCE!;
const jwks = createRemoteJWKSet(
new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`),
);
export interface Principal {
sub: string;
oid?: string;
tid?: string;
scopes: string[];
roles: string[];
claims: JWTPayload;
}
export const entraPlugin = {
name: "entra",
register(app: App) {
app.decorate("verifier", {
async verify(token: string): Promise<Principal> {
const { payload } = await jwtVerify(token, jwks, {
issuer,
audience,
algorithms: ["RS256"],
});
const scp = typeof payload.scp === "string" ? payload.scp.split(" ") : [];
const roles = Array.isArray(payload.roles) ? (payload.roles as string[]) : [];
return {
sub: String(payload.sub),
oid: payload.oid as string | undefined,
tid: payload.tid as string | undefined,
scopes: scp,
roles,
claims: payload,
};
},
});
},
};
declare module "@daloyjs/core" {
interface AppState {
verifier: { verify(token: string): Promise<Principal> };
principal?: Principal;
}
}createRemoteJWKSet caches keys in memory and refreshes on a missing kid, so key rollover is handled automatically.
5. Guard a route
import { z } from "zod";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { entraPlugin } from "./plugins/entra";
import { requireAuth } from "./plugins/auth"; // from the Overview page
const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 100 }));
app.register(entraPlugin);
app.route({
method: "GET",
path: "/me",
operationId: "getMe",
middleware: [requireAuth(process.env.ENTRA_REQUIRED_SCOPE!)],
responses: {
200: { description: "OK", body: z.object({ oid: z.string().optional(), tid: z.string().optional() }) },
},
handler: ({ state }) => ({
status: 200,
body: { oid: state.principal!.oid, tid: state.principal!.tid },
}),
});App roles vs delegated scopes: app-only tokens (client-credentials flow) put granted roles in roles with no scp claim, while user-delegated tokens put granted scopes in scp. Inspect both in requireAuth if you support both shapes.
Acquiring downstream tokens with MSAL Node
If your API needs to call Microsoft Graph or another protected service on behalf of the user, use MSAL Node's ConfidentialClientApplication:
pnpm add @azure/msal-nodeimport { ConfidentialClientApplication } from "@azure/msal-node";
const msal = new ConfidentialClientApplication({
auth: {
clientId: process.env.ENTRA_CLIENT_ID!,
authority: `https://login.microsoftonline.com/${process.env.ENTRA_TENANT_ID}`,
clientSecret: process.env.ENTRA_CLIENT_SECRET!, // or use a certificate
},
});
// On-behalf-of: exchange the incoming user token for a Graph token
const result = await msal.acquireTokenOnBehalfOf({
oboAssertion: incomingUserAccessToken,
scopes: ["https://graph.microsoft.com/User.Read"],
});
console.log(result?.accessToken);Prefer certificates over client secretsin production, and store credentials in Azure Key Vault or your platform's secret manager.
Notes
- The
issuerfor v2.0 tokens ishttps://login.microsoftonline.com/{tenantId}/v2.0. Multi-tenant apps must validate thetidclaim against an allowlist rather than relying on the issuer alone. - Don't validate tokens you don't own. Microsoft Graph tokens may not be JWTs and aren't meant to be inspected by your app.
- Entra ID rotates signing keys regularly — never pin keys, always resolve them through the JWKS endpoint.
See also AWS Cognito, Auth0, and the auth integrations overview.