Search docs

Jump between documentation pages.

Browse docs

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 Authorization header 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.).

Entra ID v2.0 access-token verification
Client appEntra IDDaloyJS APITenant JWKS
  1. 01asyncClient appEntra IDUser signs in; Entra ID mints a v2.0 access token (RS256)aud = api://my-daloy-api
  2. 02requestClient appDaloyJS APICall API with Authorization: Bearer <access token>
  3. 03asyncDaloyJS APITenant JWKScreateRemoteJWKSet fetches signing keys (cached)GET /{tenantId}/discovery/v2.0/keys
  4. 04noteDaloyJS APIDaloyJS APIjwtVerify checks issuer, audience, scp / rolesiss = login.microsoftonline.com/{tenantId}/v2.0
  5. 05responseDaloyJS APIClient appReturn protected data after requireAuth passes
The DaloyJS API verifies the v2.0 token against the tenant's JWKS with jose. Keys refresh automatically on a missing kid, so signing-key rollover needs no redeploy. Multi-tenant apps must also validate the tid claim against an allowlist.

1. Register the API in Entra ID

  1. In the Microsoft Entra admin center, go to Entra ID → App registrations → New registration and register your API app.
  2. 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).
  3. Register your client app separately and grant it permission to the scope above. Note the tenant ID and the API app's Application (client) ID.
  4. The OIDC discovery document lives at https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration and references the JWKS at https://login.microsoftonline.com/{tenantId}/discovery/v2.0/keys.

2. Install

ts
pnpm add jose

Add @azure/msal-node as well only if the API itself needs to acquire downstream tokens (see below).

3. Environment variables

ts
# .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_user

4. Plugin

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

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

ts
pnpm add @azure/msal-node
ts
import { 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 secrets in production, and store credentials in Azure Key Vault or your platform's secret manager.

Notes

  • The issuer for v2.0 tokens is https://login.microsoftonline.com/{tenantId}/v2.0. Multi-tenant apps must validate the tid claim 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.