Skip to content

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 built-in jwk() does that with no extra package, on every DaloyJS runtime. 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 = the API app's client ID (GUID)
  2. 02requestClient appDaloyJS APICall API with Authorization: Bearer <access token>
  3. 03asyncDaloyJS APITenant JWKSjwk() fetches signing keys (cached)GET /{tenantId}/discovery/v2.0/keys
  4. 04noteDaloyJS APIDaloyJS APIjwk() checks issuer, audience, expiry; guards check scp / rolesiss = login.microsoftonline.com/{tenantId}/v2.0
  5. 05responseDaloyJS APIClient appReturn protected data after the scope or role guard passes
The DaloyJS API verifies the v2.0 token against the tenant's JWKS with jwk(). The JWKS is cached and refetched once fetchTtlSeconds (default 300 s) expires, so signing-key rollover needs no redeploy. A token whose kid is not in the cached set is rejected until then. 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. In the API app's Manifest, set api.requestedAccessTokenVersion to 2. The default (null) issues v1.0 access tokens, whose iss does not end in /v2.0 and whose aud may be the Application ID URI, so the configuration below would reject them.
  4. 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.
  5. 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

Nothing beyond @daloyjs/core. Add @azure/msal-node only if the API itself needs to acquire downstream tokens (see below).

3. Environment variables

bash
# .env
ENTRA_TENANT_ID=11111111-2222-3333-4444-555555555555
ENTRA_API_AUDIENCE=00001111-aaaa-2222-bbbb-3333cccc4444   # the API app's Application (client) ID; v2.0 tokens never carry the api:// URI in aud

4. Verify tokens and guard routes

ts
import { App, every, jwk, requireRoles, requireScopes } from "@daloyjs/core";

const tenantId = process.env.ENTRA_TENANT_ID!;

const app = new App({
  requestTimeoutMs: 150_000,
  // Slow upstream work (an LLM, Graph) is a server-side timeout, not the client's fault.
  requestTimeoutStatus: 504,
});

app.use(every(
  jwk({
    jwks: `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`,
    algorithms: ["RS256"],
    issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`,
    audience: process.env.ENTRA_API_AUDIENCE!,
    // Also makes `exp` mandatory: without a lifetime cap, jwk() checks exp
    // only when the token carries one.
    maxLifetimeSeconds: 86_400,
  }),
  // Authorize before the body is read; any one accepted scope is enough.
  requireScopes(["access_as_user", "admin"], { phase: "preBody", match: "any" }),
));

jwk() fetches the tenant JWKS over HTTPS with caching, requires a kid, pins the algorithm allowlist, and checks iss, aud, exp and nbf. Since 1.10.0 it reads Entra's scp claim, which is a space-delimited string ("scp": "access_as_user User.Read"), into ctx.state.user.scopes. Earlier versions only accepted an array there, so every Entra user failed requireScopes() with 403.

With both hooks in preBody, the status codes do not depend on the request body: missing, expired or wrongly signed tokens are 401, a valid token without an accepted scope is 403, and only an authorized caller can get a 400 for a malformed body. On Node, add serve(app, { bufferedBodyMaxBytes: 0 }) if unauthenticated clients sending Expect: 100-continue must never be invited to upload; see request-body buffering.

App roles

Define App roles on the API registration and assign them to users, groups, or client applications. Granted roles arrive in the roles claim, which requireRoles() reads by default:

ts
app.delete(
  "/books/:id",
  {
    hooks: requireRoles(["Books.Admin"]),
    responses: { 204: { description: "Deleted" } },
  },
  () => ({ status: 204, body: undefined }),
);

App roles vs delegated scopes: app-only tokens (client-credentials flow, no user) carry granted application roles in roles and no scp, while user-delegated tokens carry scopes in scp. Guard machine-to-machine routes with requireRoles() and user routes with requireScopes(). If one route must accept both callers, write a small preBody hook that passes when either ctx.state.user.scopes has the scope or ctx.state.user.claims.roles has the role.

Multi-tenant apps

The issuer above pins one tenant. A multi-tenant API that accepts several tenants must check the tid claim against an allowlist, in jwk({ verify }):

ts
const allowedTenants = new Set(process.env.ENTRA_ALLOWED_TENANTS!.split(","));

jwk({
  // ...as above, with the issuer for each tenant you accept
  verify: (claims) => allowedTenants.has(String(claims.tid)), // false -> 403
});

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.

Edit this page on GitHub

Last updated