Identity Azure

Mastering Entra ID Tokens: App Roles, Group Claims, and the OAuth2 On-Behalf-Of Flow for APIs

Every Entra ID authorization bug I have ever debugged traced back to one of two mistakes: someone trusted a claim that was not in the token, or they trusted a token that was never meant for their API. Both are avoidable, and both keep happening because the token is opaque until you decode it, and once you decode it the claims look self-explanatory when they are not. scp and roles look interchangeable. aud looks like a formality. The signing key looks like something you can cache and forget. Every one of those assumptions is a production incident waiting for load, a new client, or a signing-key rollover.

This is the model I use for a real multi-tier application on Microsoft Entra ID (the identity platform formerly branded Azure AD): a single-page app or web frontend that calls an API, and that API in turn calls Microsoft Graph or another downstream API on the signed-in user’s behalf. We will take the token apart claim by claim — ID token versus access token versus refresh token, the JWT header/payload/signature, and the specific claims (aud, iss, scp, roles, azp, oid, tid, idtyp, nonce) that drive real decisions. We will design authorization deliberately with app roles, delegated scopes, and group claims, wire the On-Behalf-Of (OBO) flow the way it works on the wire and in MSAL (with the preconditions that cause most failures), and validate tokens the way an internet-facing API must — signature against the published JWKS, issuer and audience pinned to this tenant and this API, expiry with bounded skew, and authorization by scp/roles and never a substring match.

By the end you will stop guessing. When a call returns 401 or 403, you will decode first and correlate second, tell a platform 403 from an application 403, read an AADSTS code straight to its fix, and know whether you face a wrong audience, a missing scope, an unconsented permission, an overflowed groups claim, or a rolled signing key. This is Expert material: it assumes you know OAuth2 and OIDC and goes straight for the corners where senior engineers get cut.

What problem this solves

Delegated authorization across service boundaries is genuinely hard, and Entra ID’s abstractions hide the hard parts until they bite. A frontend obtains a token, sends it to your API, and your API needs to do something downstream as the user — read the user’s mail, list their groups, call a partner API scoped to them. Naively you might forward the incoming token to Graph. It fails, because that token’s audience is your API, not Graph, and Entra ID refuses a token whose audience is not the resource being called. So you need a flow that exchanges “a token for me, from the user” into “a token for Graph, still as the user” — that is OBO — wired with the right preconditions or it returns opaque AADSTS codes at three in the morning.

What breaks without this knowledge is a specific, recurring set of incidents. An API accepts a token meant for Microsoft Graph because its validator never pinned the audience — a real privilege-escalation surface, because any app that can get a Graph token could hit your API. A daemon calling your API gets 403 with an empty roles claim because nobody assigned the application app role. A validator caches the signing key and dies on the next rollover, taking every call down until someone redeploys. A regional manager in 300 security groups is silently denied because the groups claim overflowed and the code read the absence as “no groups.” Each is a one-line fix and a genuine outage if you miss it.

Who hits this: every team building protected APIs on Entra ID — internal line-of-business apps, multi-tenant SaaS, anything behind Azure API Management or App Service EasyAuth, any middle tier that touches Graph. It bites hardest on teams that treat the token as a black box and the platform’s defaults as safe. They are not: consent is not granted for you, appRoleAssignmentRequired is off by default, group claims overflow silently, and the difference between scp and roles is invisible until an app-only caller shows up with no user. The whole discipline is: know exactly what is in the token, know exactly what your API is allowed to trust, and make the two meet.

To frame the entire field before the deep dive, here is the multi-tier problem, the token that flows at each hop, and the single most important thing to check:

Hop Token in play Audience (aud) Who is present The one thing to get right
Browser/SPA → Entra ID (sign-in) ID token + access token ID token: the client; access token: the API The user (interactive) ID token stays on the client; access token goes to the API
SPA → your API Access token A api://<your-api> The user (delegated) Validate aud, iss, then scp (or roles)
Your API → Entra ID (OBO) Exchange A for token B Token B: Microsoft Graph Still the user Incoming aud must be you; downstream permission consented
Your API → Graph Access token B https://graph.microsoft.com Still the user scp on B carries the delegated Graph scope
Daemon → your API Access token (client credentials) api://<your-api> No user (idtyp: app) Authorize on roles, never on scp

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with OAuth2 roles (resource owner, client, authorization server, resource server) and the authorization-code + PKCE and client-credentials grants, and with OpenID Connect as the identity layer on top of OAuth2. You should know that Entra ID has application objects (the global definition of an app) and service principal objects (the per-tenant instance of that app), and that these are distinct — much of app-role assignment turns on the difference. Familiarity with az in Cloud Shell, reading JSON, and ASP.NET Core or another JWT-validating web framework helps; the code examples use az, the Microsoft Graph REST API via az rest, and MSAL/Microsoft.Identity.Web.

This sits in the Identity → application security track. It is downstream of the registration mechanics in Building a Secure OIDC Confidential Client in Entra ID: App Registrations, Secrets, and Workload Identity Federation — that article covers how the app authenticates itself; this one covers what the token contains and how the API trusts it. It pairs tightly with Governing OAuth Consent and Application Permissions in Entra ID: Stopping Illicit Consent and Hardening App Trust, because consent is a precondition of OBO and a governance surface in its own right. When the middle tier authenticates with a certificate or federated credential instead of a secret, Managed Identities Deep Dive: User-Assigned Identities, Federated Credentials, and RBAC Patterns for Azure Workloads is the companion. If your API sits behind API Management, the JWT-validation policy there is covered in APIM Policies From Scratch: Rate-Limit-By-Key, JWT Validation and Request/Response Transformation.

Where the moving parts live, and who typically owns each, so you escalate to the right place fast:

Object What it holds Portal location Owned by Failure it causes
Application (app registration) Scopes, app roles, optional claims, redirect URIs, credentials Entra admin center → App registrations App team Missing scope/role definition; wrong identifierUri
Service principal (enterprise app) App-role assignments, appRoleAssignmentRequired, claims mapping policy Entra admin center → Enterprise applications App + identity team Unassigned users; missing roles; assignment-required off
Consent grants Which permissions are consented, by whom, for which resource Enterprise app → Permissions Identity/security team AADSTS65001; OBO fails downstream
Token configuration Optional claims, group-claims setting App registration → Token configuration App team Missing email/upn; group overage
Conditional Access Session/step-up policies affecting token issuance Entra → Conditional Access Security team AADSTS50076/50079 (interaction required)
Your API’s validator Audience/issuer/JWKS/scope enforcement Your code / APIM policy App team Wrong-audience acceptance; 403 on valid token

Core concepts

Six mental models make every later section obvious.

Tokens have owners, and the owner is not always who you think. An access token is issued for a resource (the aud claim) and is meant to be consumed only by that resource. An ID token is issued for a client and is proof of authentication for that client — it is not a credential to call anything. A refresh token is issued to a client to get new access tokens without re-prompting the user. The single most consequential rule in this entire article: your API validates the access token whose aud is your API and nothing else. It never inspects an ID token (that belongs to the client), and it rejects any access token whose audience is not its own identifier — including a token for Microsoft Graph, which is emphatically not a token for you.

A JWT is three base64url segments, and only the middle one is data. header.payload.signature. The header names the algorithm (alg, always an asymmetric RS256/PS256 for Entra ID — never trust an alg: none token) and the key id (kid) that tells you which signing key to verify against. The payload is the claims. The signature is computed by Entra ID’s private key over header.payload; you verify it with the matching public key from the tenant’s JWKS. Decoding a JWT is trivial and proves nothing — anyone can read and even forge the payload. Validating it — checking the signature and the claims — is the entire security boundary.

scp means a user is present; roles (on an app-only token) means no user. Delegated scopes (scp, space-delimited) appear when a user is signed in and has delegated authority to a client; the effective permission is the intersection of what the app was granted and what the user is allowed to do. App roles (roles, an array) appear either in a delegated token (a role assigned to the user/group) or in an app-only token (a role assigned to the application, for daemons). The presence of idtyp: app — or equivalently the absence of any user-context claim like a delegated scp — tells you there is no user. Authorizing an app-only call on scp is a bug (it will not be there); authorizing a daemon on a user’s oid is a bug (there is no user). Treat the two token shapes as two different things.

The signature key rotates, and you must not pin it. Entra ID publishes its current signing keys at a JWKS (JSON Web Key Set) endpoint discovered from OIDC metadata, and it rotates those keys. A validator that hardcodes a key or caches JWKS forever works until the next rollover, then rejects every token because the kid no longer matches. The correct behaviour is to fetch keys from OIDC discovery and re-fetch on an unknown kid. Every mature library (Microsoft.Identity.Web, jwt-validating middleware, APIM’s validate-jwt) does this for you; the danger is only in hand-rolled validators.

Consent is a precondition, not a formality, and it gates OBO. For your API to call Graph on the user’s behalf, three things must be true: your API’s registration must request the downstream delegated permission, that permission must be consented (by the user or an admin) in the tenant, and the incoming token must actually be a token for your API carrying a scope you exposed. Miss the consent and OBO returns AADSTS65001. Consent is also a security boundary — the OAuth consent governance discipline exists precisely because over-broad consent is how illicit-consent attacks work.

Token lifetime is fixed-ish; CAE is the near-real-time escape hatch. Access tokens default to roughly 60–90 minutes (Entra ID randomizes within a window). You generally cannot shorten this arbitrarily anymore — the old configurable-token-lifetime knobs for access tokens were retired in favour of Continuous Access Evaluation (CAE), which lets a CAE-capable resource (Graph, and your API if you opt in) revoke a session in near real time on critical events (user disabled, password reset, risk detected, network location change) instead of waiting for expiry. Understanding what CAE covers and what still only expires is the difference between “the fired employee lost access instantly” and “the fired employee had a valid token for another 55 minutes.”

The vocabulary in one table

Before the deep sections, pin down every term. The glossary repeats these for lookup; this is the mental model side by side.

Concept One-line definition Where it lives Why it matters
ID token Proof of authentication for a client Returned to the client Never sent to an API; client-only
Access token Credential to call a resource Sent to the resource in Authorization The thing an API validates
Refresh token Long-lived token to mint new access tokens Held by the client/MSAL cache Enables silent renewal; never sent to a resource
aud Audience — the resource the token is for Access/ID token claim Must equal your API’s identifier
iss Issuer — the token-issuing authority Token claim Must match your tenant’s expected issuer
scp Delegated scopes (space-delimited) Delegated access token User present; intersection of app + user rights
roles App roles granted Access token (delegated or app-only) Coarse RBAC; app-only ⇒ no user
azp / appid Authorized party — the client app ID Access token Which client requested the token
oid Immutable object ID of the principal Token claim Stable user/app identity key
tid Tenant ID Token claim Home tenant of the principal
idtyp app on an app-only token Access token Distinguishes app vs user calls
nonce Replay-binding value ID token Must equal the value the client sent
JWKS The set of public signing keys jwks_uri from OIDC discovery Verifies the signature; rotates
App role A named permission defined on an app Application appRoles Assigned to users/groups or apps
Delegated scope A permission a client exercises for a user Application oauth2PermissionScopes Appears as scp
OBO flow Exchange a token-for-me into a token-for-downstream Token endpoint / MSAL Multi-tier delegation
Consent Recorded permission grant oauth2PermissionGrants / appRoleAssignments Precondition for calling a resource
CAE Near-real-time session revocation Resource + client opt-in Kills sessions before token expiry

The three token types, precisely

The most common architectural mistake is treating “the token” as one thing. Entra ID issues three, with different purposes, audiences, formats, and rules about who may read them. Get this wrong and you either leak authentication proof to an API that will happily accept it (a spoofing risk) or you send a credential where none should go.

Property ID token Access token Refresh token
Purpose Prove the user authenticated Authorize a call to a resource Get new access/ID tokens silently
Audience (aud) The client app ID The resource (API) identifier Not a JWT; opaque to you
Who consumes it The client (frontend) The resource server (API) Entra ID token endpoint only
Format Signed JWT (readable) Signed JWT (v2.0) or opaque-ish (some v1.0 Graph tokens) Opaque string — do not parse
Typical lifetime ~60–90 min ~60–90 min (randomized) up to 90 days inactivity / rolling
Contains scp/roles? No (identity claims only) Yes N/A
Should an API validate it? Never Always N/A
Sent in Authorization: Bearer? No Yes No
Bound to replay via nonce? Yes (OIDC) No No

The ID token answers “who signed in, and did they really?” It carries identity claims — sub, oid, name, preferred_username, email (if configured), tid — and OIDC-specific claims nonce (replay binding), at_hash, c_hash. Its audience is the client. A frontend validates the ID token to establish a session; it must check the nonce matches what it sent (defeating replay) and, for the implicit-ish hybrid flows, the c_hash/at_hash. Crucially, an API must never accept an ID token — its audience is the client, not the API, and it contains no scp/roles to authorize against. Teams that “just check the token is signed and from our tenant” and accept whatever arrives have opened a hole: an ID token, replayed to the API, passes those weak checks.

The access token is the credential. Its audience is the resource. For a v2.0 app it is a readable JWT carrying aud, iss, scp or roles, azp, oid, tid, ver, exp. This is the only token your API validates. Note a subtlety: access tokens are for the resource, and the resource owns their format. Microsoft Graph’s own access tokens are intentionally not a stable, documented format you should parse in your code — but tokens Entra ID issues for your API (a v2.0 app) are standard JWTs you validate normally. You validate tokens for your audience; you never crack open a token whose audience is someone else’s resource.

The refresh token is opaque and long-lived. It is held by the client (in a SPA, in memory or a secure store via MSAL; in a confidential client, in the token cache). The client presents it to the token endpoint to get a fresh access token without re-prompting. You never send a refresh token to a resource, never parse it (it has no meaningful public structure), and never log it — it is a bearer credential that can mint access tokens. Its lifetime is governed by Entra ID’s refresh-token policies (rolling window, up to 90 days of inactivity by default, subject to CA session controls and CAE).

A v1.0 versus v2.0 note that saves hours: the token version is set by the app registration’s accessTokenAcceptedVersion (and by which endpoint the client hits). v1.0 and v2.0 tokens differ in iss (sts.windows.net/<tid>/ vs login.microsoftonline.com/<tid>/v2.0), in aud format, and in a few claim names. Validate against the version you actually issue.

Endpoint / setting Issues iss format aud for your API Notes
v1.0 endpoint (/oauth2/authorize) v1.0 tokens https://sts.windows.net/<tid>/ App ID GUID or api://… Legacy; appid (not azp)
v2.0 endpoint (/oauth2/v2.0/authorize) v2.0 tokens https://login.microsoftonline.com/<tid>/v2.0 api://… or client GUID Modern; azp, ver: 2.0
accessTokenAcceptedVersion: 1 v1.0 access tokens even from v2 endpoint sts.windows.net as above Set on the resource app
accessTokenAcceptedVersion: 2 (or null→2 for new apps) v2.0 access tokens login.microsoftonline.com/.../v2.0 as above Recommended for new APIs

JWT anatomy: reading every claim that matters

A v2.0 access token issued by Entra ID for a middle-tier API looks like this once decoded (the header, then the payload):

// HEADER
{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "nOo3ZDrODXEK1jKWhXslHR_KXEg"
}
// PAYLOAD (claims)
{
  "aud": "api://kv-orders-api",
  "iss": "https://login.microsoftonline.com/72f988bf-.../v2.0",
  "iat": 1717326000,
  "nbf": 1717326000,
  "exp": 1717329600,
  "azp": "11111111-2222-3333-4444-555555555555",
  "azpacr": "1",
  "scp": "Orders.Read Orders.Write",
  "roles": ["Orders.Admin"],
  "oid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "sub": "subject-pairwise-id-unique-per-app",
  "tid": "72f988bf-86f1-41af-91ab-2d7cd011db47",
  "preferred_username": "alice@contoso.com",
  "name": "Alice Managed",
  "ver": "2.0"
}

Every claim here does a job. The ones that drive authorization decisions are the ones you must never misread:

Claim Meaning Type Authorization use Gotcha
aud Audience — who the token is for string Must equal your API’s identifier Graph’s GUID 00000003-0000-0000-c000-000000000000 is not you
iss Issuer authority string Must match your tenant’s issuer exactly v1.0 sts.windows.net vs v2.0 /v2.0
scp Delegated scopes space-delimited string Present ⇒ user present, delegated It is ONE string; split on spaces, never substring-match
roles App roles granted string array Coarse RBAC (user or app) On app-only token ⇒ no user
azp Authorized party (client app ID) string Which client requested the token v1.0 uses appid instead
azpacr Client auth method string 0=public, 1=secret, 2=cert Enforce 2 for high-value APIs
oid Object ID of the principal GUID Stable identity key Same user, different sub per app
sub Subject (pairwise per app) string Per-app stable user id NOT stable across apps; use oid for cross-app
tid Tenant ID GUID Home tenant Pin in multi-tenant apps to allow-list tenants
idtyp app on app-only tokens string Distinguish app vs user Absent on delegated tokens
nonce Replay binding string ID token only Client must match what it sent
wids Tenant-wide role template IDs GUID array Directory roles (e.g. Global Admin) For directory-role checks, not app roles
groups Group object IDs GUID array Group-based authz Overflows_claim_names
hasgroups Overage flag (alt form) bool Signals groups exist but were omitted Must trigger a Graph call
acct Account status (0=member,1=guest) int Guest vs member decisions Optional claim
ver Token version string 1.0 or 2.0 Governs iss/aud shape
iat/nbf/exp Issued/not-before/expiry (epoch) int Time validity Allow small skew (±5 min)

Three reading traps that cause the most wasted hours:

Trap The mistake The reality
scp substring match if (scp.Contains("Orders.Read")) Orders.Read is a substring of Orders.ReadBasic; split on space and compare tokens
Treating sub as a user id Joining data on sub across two apps sub is pairwise — different per client app for the same user; use oid
Reading roles as “user roles” Assuming roles implies a user On an app-only token roles are the application’s roles; no user exists

The scp/roles distinction deserves its own decision table, because it is the crux of the whole model:

Token shape scp present? roles present? idtyp User present? Authorize on
Delegated, scope-based Yes Maybe (user-assigned role) absent Yes scp (and/or roles)
Delegated, role-based only No Yes (user/group role) absent Yes roles
App-only (daemon) No Yes (application role) app No roles only
App-only with no role assigned No No app No Nothing → deny (misconfig)

Exposing an API: delegated scopes versus app roles

When you expose an API, Entra ID gives you two authorization primitives, and they answer different questions. Choosing the wrong one produces an app that “works” until an edge case (a daemon, a user without the role) reveals the mismatch.

Pick deliberately:

Requirement Use Why
User-facing API where the user’s own rights bound the client Delegated scopes Intersection semantics; client never exceeds user
Coarse role tiers (Admin/Reader/Approver) for users App roles → users/groups Stable roles claim; no directory-query at runtime
Daemon/service calling with no user (nightly job, webhook) App roles → applications App-only token; roles carries the grant
Both: user-context action + a role tier Scopes for the action + app roles for the tier scp gates the operation, roles gates the level
Fine-grained per-object authorization Neither alone — use claims/groups + app-side checks Tokens carry coarse grants; do fine-grained in code/data

The two primitives side by side, because the differences are exactly what trips people up:

Dimension Delegated scope (scp) App role (roles)
Question answered What can the client do for the user? What can this principal do?
Assigned to Consented by user/admin to the client Assigned to users/groups or applications
Requires a user? Yes (delegated) User (delegated) or app-only
Effective permission Intersection of app grant ∩ user rights The role as assigned (no intersection)
Claim shape One space-delimited string JSON array of strings
Consent needed? Yes (delegated consent) Assignment (and admin consent for app perms)
Good for User-bounded operations Coarse RBAC, daemons
Enforcement RequireScope / parse scp RequireRole / check roles

Expose a delegated scope by patching the application’s api.oauth2PermissionScopes via Microsoft Graph — the az ad app surface does not cleanly edit this, so use az rest:

APP_OBJECT_ID=$(az ad app list --display-name "kv-orders-api" --query "[0].id" -o tsv)

az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body '{
    "identifierUris": ["api://kv-orders-api"],
    "api": {
      "requestedAccessTokenVersion": 2,
      "oauth2PermissionScopes": [{
        "id": "0f0f0f0f-1111-2222-3333-444444444444",
        "value": "Orders.Read",
        "type": "User",
        "adminConsentDisplayName": "Read orders",
        "adminConsentDescription": "Allows the app to read orders on behalf of the signed-in user.",
        "userConsentDisplayName": "Read your orders",
        "userConsentDescription": "Allows the app to read your orders.",
        "isEnabled": true
      }]
    }
  }'

The equivalent registration expressed as infrastructure-as-code — the azuread Terraform provider models scopes and roles as blocks on the application resource, which is the reviewable, drift-detectable way to manage them:

resource "azuread_application" "orders_api" {
  display_name     = "kv-orders-api"
  identifier_uris  = ["api://kv-orders-api"]

  api {
    requested_access_token_version = 2

    oauth2_permission_scope {
      id                         = "0f0f0f0f-1111-2222-3333-444444444444"
      value                      = "Orders.Read"
      type                       = "User"
      admin_consent_display_name = "Read orders"
      admin_consent_description  = "Allows the app to read orders on behalf of the signed-in user."
      user_consent_display_name  = "Read your orders"
      user_consent_description   = "Allows the app to read your orders."
      enabled                    = true
    }
  }
}

Generate scope and role id values as fresh GUIDs (uuidgen, [guid]::NewGuid(), or Terraform’s random_uuid). They must be unique within the app and stable once issued — assignments and consents reference them by ID, so changing an ID orphans every grant.

The type on a scope (User vs Admin) decides whether a user can consent to it themselves or whether it always requires an administrator. Use Admin for anything that reads or writes broadly:

Scope type Who can consent Use for
User The signed-in user (if user consent is allowed in the tenant) Low-risk, user-scoped reads/writes
Admin A privileged admin only Broad reads, writes affecting others, sensitive data

Defining and assigning app roles

Define app roles on the application object. allowedMemberTypes decides where a role can land: User (users and groups), Application (service principals, for daemons), or both.

az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body '{
    "appRoles": [
      {
        "id": "55555555-aaaa-bbbb-cccc-666666666666",
        "displayName": "Orders Administrator",
        "value": "Orders.Admin",
        "description": "Full administrative access to orders.",
        "allowedMemberTypes": ["User"],
        "isEnabled": true
      },
      {
        "id": "77777777-aaaa-bbbb-cccc-888888888888",
        "displayName": "Orders Processor (daemon)",
        "value": "Orders.Process",
        "description": "Allows a service to process orders without a user.",
        "allowedMemberTypes": ["Application"],
        "isEnabled": true
      }
    ]
  }'

The value is exactly what appears in the roles claim — keep it stable, because your API authorizes against it. The allowedMemberTypes choice is not cosmetic; it determines which token shape can ever carry the role:

allowedMemberTypes Assignable to Appears in Typical use
["User"] Users and security groups Delegated token roles User-facing RBAC tiers
["Application"] Service principals (other apps’ SPs) App-only token roles Daemons, service-to-service
["User","Application"] Both Either token shape APIs called by both users and services

Assigning a role to a user or group

App-role assignments attach to the service principal of the resource API (the enterprise-app object), not the application object. This is the distinction that trips everyone: you define the role on the application, you assign it on the service principal. Find the resource SP, then create the assignment.

# Object ID of the API's service principal (the RESOURCE)
RESOURCE_SP=$(az ad sp list --display-name "kv-orders-api" --query "[0].id" -o tsv)
ROLE_ID="55555555-aaaa-bbbb-cccc-666666666666"   # Orders.Admin

# principalId can be a user objectId OR a group objectId
PRINCIPAL_ID=$(az ad user show --id "alice@contoso.com" --query id -o tsv)

az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$RESOURCE_SP/appRoleAssignedTo" \
  --headers "Content-Type=application/json" \
  --body "{
    \"principalId\": \"$PRINCIPAL_ID\",
    \"resourceId\": \"$RESOURCE_SP\",
    \"appRoleId\": \"$ROLE_ID\"
  }"

Assigning to a group requires that the group is assignable to the app, and there is a sharp limitation to internalize: nested groups do not transitively grant app roles. Only direct members of the assigned group get the role in their token. A user in a child group of the assigned parent gets nothing. If you rely on nesting for other authorization and expect it here, users will be silently denied.

For a daemon, set principalId to the client app’s service principal object ID (not a user) and use an Application role:

CLIENT_SP=$(az ad sp list --display-name "kv-nightly-processor" --query "[0].id" -o tsv)
APP_ROLE_ID="77777777-aaaa-bbbb-cccc-888888888888"   # Orders.Process (Application)

az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$RESOURCE_SP/appRoleAssignedTo" \
  --headers "Content-Type=application/json" \
  --body "{
    \"principalId\": \"$CLIENT_SP\",
    \"resourceId\": \"$RESOURCE_SP\",
    \"appRoleId\": \"$APP_ROLE_ID\"
  }"

The same assignment as Terraform, so it is reviewed and drift-detected rather than clicked:

resource "azuread_app_role_assignment" "processor" {
  app_role_id         = "77777777-aaaa-bbbb-cccc-888888888888"
  principal_object_id = azuread_service_principal.nightly_processor.object_id
  resource_object_id  = azuread_service_principal.orders_api.object_id
}

Set Assignment required (appRoleAssignmentRequired: true on the resource SP) when you want app roles to be the gate. With it off (the default), unassigned users still authenticate successfully and simply receive no roles claim — so if your API denies on missing role, they get 403 but could still get a valid token, which surprises people during testing.

az ad sp update --id "$RESOURCE_SP" --set "appRoleAssignmentRequired=true"

The assignment-required behaviour, made explicit because it is the source of “why did this user get a token but no role?”:

appRoleAssignmentRequired Unassigned user can sign in? Token issued? roles claim Result at your API (deny-on-missing-role)
false (default) Yes Yes absent 403 (your policy denies)
true No No (blocked at Entra) n/a AADSTS50105 at sign-in

Group claims without token bloat

You can emit a user’s group memberships directly in the token via groupMembershipClaims. It is convenient and a genuine footgun at scale.

az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body '{ "groupMembershipClaims": "SecurityGroup" }'

The values you can set, and what each pulls into the token:

groupMembershipClaims value Emits Scope Overage risk
"None" (default) No groups claim None
"SecurityGroup" Security groups + directory roles the user is in All the user’s security groups High
"ApplicationGroup" Only groups assigned to this application Just relevant groups Low (bounded set)
"DirectoryRole" Directory roles only Admin roles Low
"All" Security + distribution + roles Everything Highest

The problem: tokens are size-limited, and when a user belongs to more groups than the cap, Entra ID drops the groups claim entirely and emits an overage indicator instead — a _claim_names/_claim_sources pair pointing at a Graph URL:

{
  "_claim_names": { "groups": "src1" },
  "_claim_sources": {
    "src1": {
      "endpoint": "https://graph.microsoft.com/v1.0/users/<oid>/getMemberObjects"
    }
  }
}

The caps, and why “it worked in dev” is the classic failure — a test user in five groups never overflows; a regional manager in 300 does:

Token type Approx group cap before overage What happens above it
JWT (access/ID token) ~150 groups groups dropped; _claim_names/_claim_sources emitted
SAML token ~200 groups groups dropped; overage indicator emitted
Implicit flow (SPA, historical) ~6 groups Even tighter cap; strongly prefer app roles

Your API must detect overage and call Graph to retrieve the full set, or authorization silently breaks for exactly your highest-privilege users. The robust patterns, in order of preference:

  1. Prefer app roles over raw group claims. A roles claim does not overflow the same way and is the right abstraction for authorization. This is the single best mitigation.
  2. If you need groups, scope them: set groupMembershipClaims: "ApplicationGroup" so you only carry groups assigned to the app and never overflow on irrelevant memberships.
  3. Handle the overage path explicitly — call getMemberObjects (or transitiveMemberOf) with securityEnabledOnly: true, using an OBO token under an already-consented scope.
// Resolve groups, honoring overage. `graph` is a GraphServiceClient built on an OBO token.
List<string> groups;
if (principal.HasClaim(c => c.Type == "_claim_names"))
{
    // Overage: the token omitted groups — fetch them from Graph
    var page = await graph.Users[oid].GetMemberObjects
        .PostAsGetMemberObjectsPostResponseAsync(new() { SecurityEnabledOnly = true });
    groups = page?.Value ?? new();
}
else
{
    groups = principal.FindAll("groups").Select(c => c.Value).ToList();
}

The overage-handling decision, as a table you can code straight from:

Condition in the token Interpretation Action
groups claim present Full set fits Read groups directly
_claim_names.groups present (no groups) Overage Call Graph getMemberObjects/transitiveMemberOf
hasgroups: true (no groups) Overage (alternate form) Same Graph call
Neither present, groupMembershipClaims = None Not configured Groups not in play; use roles
Neither present, but you expected groups Misconfig or genuinely zero groups Do not assume zero — check config

The On-Behalf-Of flow, end to end

OBO solves the multi-hop problem: a user calls your API with access token A (aud = your API); your API needs to call Microsoft Graph or another downstream API as that user. You exchange token A for a new token B (aud = Graph) at the token endpoint. You cannot forward A to Graph — its audience is your API, and Graph will reject it.

The wire request — note grant_type=jwt-bearer and requested_token_use=on_behalf_of, and the incoming user token supplied as assertion:

curl -X POST \
  "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
  -d "client_id=$API_CLIENT_ID" \
  -d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
  -d "client_assertion=$SIGNED_CLIENT_ASSERTION" \
  -d "assertion=$INCOMING_USER_ACCESS_TOKEN" \
  -d "scope=https://graph.microsoft.com/User.Read" \
  -d "requested_token_use=on_behalf_of"

Every parameter, and why it is there:

Parameter Value Role
grant_type urn:ietf:params:oauth:grant-type:jwt-bearer Selects the OBO/JWT-bearer grant
requested_token_use on_behalf_of Tells Entra this is an OBO exchange
client_id Your API’s client ID Identifies the middle tier requesting B
client_assertion_type …jwt-bearer Middle tier authenticates with a cert/FIC, not a secret
client_assertion Signed JWT proving the API’s identity The middle tier’s own credential
assertion The incoming user access token A The delegation proof (the user’s token)
scope The downstream scope (e.g. …/User.Read) What token B will be good for

In production, use MSAL rather than hand-rolling this. MSAL caches the OBO result keyed by the incoming token, so you are not hitting the token endpoint on every request:

// Confidential client configured with a certificate or federated credential
var result = await app.AcquireTokenOnBehalfOf(
        new[] { "https://graph.microsoft.com/.default" },
        new UserAssertion(incomingAccessToken))
    .ExecuteAsync();
// result.AccessToken now has aud = Microsoft Graph, still as the user

With Microsoft.Identity.Web the plumbing collapses further — you inject a GraphServiceClient that performs OBO transparently:

// Program.cs
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
        .EnableTokenAcquisitionToCallDownstreamApi()   // turns on OBO
        .AddMicrosoftGraph(builder.Configuration.GetSection("Graph"))
        .AddInMemoryTokenCaches();

// Controller — the framework does OBO under the hood
[Authorize]
[RequiredScope("Orders.Read")]
public async Task<IActionResult> Get([FromServices] GraphServiceClient graph)
{
    var me = await graph.Me.GetAsync();     // OBO token acquired + cached automatically
    return Ok(me?.DisplayName);
}

The three preconditions that cause most OBO failures

OBO fails in a small number of specific, diagnosable ways. Internalize these three preconditions and you pre-empt the majority of AADSTS codes:

The OBO failure map — symptom to precondition to fix:

Symptom / error Which precondition failed Confirm Fix
AADSTS65001 consent required Downstream permission not consented Enterprise app → Permissions shows the scope ungranted Grant admin consent for the Graph permission
AADSTS500131 / invalid_grant “assertion audience” Incoming token aud ≠ your API Decode A: aud is Graph or another API Frontend must request api://your-api/scope, not Graph
AADSTS50013 invalid assertion Incoming token expired/malformed as assertion Decode A: exp in the past Have the client refresh A before calling you
AADSTS50076/50079 interaction required CA/MFA needed for the downstream claims challenge returned Return a 401 with the claims challenge; client re-auths
AADSTS70011 invalid scope Downstream scope string wrong Check the requested scope string Use https://graph.microsoft.com/User.Read (or /.default)
OBO returns app-only token (no user) Requested /.default on a client-cred path Decode B: idtyp: app, no user Ensure the assertion (user token) is passed; use delegated scope

Authenticate the middle tier with a certificate or federated credential (client_assertion), not a client secret. OBO with workload identity federation removes the long-lived secret on the middle tier entirely — see Managed Identities Deep Dive: User-Assigned Identities, Federated Credentials, and RBAC Patterns. A leaked middle-tier secret is a leaked ability to impersonate every user who ever called your API.

Consent: user, admin, and why it gates everything

Consent is the recorded agreement that a client (or a middle tier) may exercise a permission. It comes in two forms and is a first-class governance surface.

Delegated consent grants a client the right to act for a user within a scope. It can be user consent (the signed-in user agrees, only if the tenant permits user consent and the scope is type: User) or admin consent (a privileged admin agrees on behalf of the whole tenant). Admin consent is mandatory for type: Admin scopes and for any application (app-only) permission.

Application consent (app-role grant) authorizes a service principal to hold an application permission — always admin-only, because there is no user to consent. The consent surfaces and where they are recorded:

Consent kind Who grants Applies to Stored as When required
User consent (delegated) The signed-in user This user only oauth2PermissionGrants (consentType Principal) type: User scope, user consent allowed
Admin consent (delegated) Privileged admin The whole tenant oauth2PermissionGrants (consentType AllPrincipals) type: Admin scope, or tenant-wide
Application (app role) grant Privileged admin The app’s SP appRoleAssignments Any application permission (daemon)

Grant admin consent for your middle tier’s Graph permission so OBO can succeed:

# Grant admin consent for the app's delegated Graph permissions in this tenant
az ad app permission admin-consent --id "$API_CLIENT_ID"

# Verify the delegated grant landed (consentType AllPrincipals = admin-consented tenant-wide)
az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$API_SP_ID/oauth2PermissionGrants" \
  --query "value[].{resource:resourceId, scope:scope, type:consentType}" -o table

The reason OAuth consent governance is its own discipline: an attacker who tricks a user into consenting to an over-broad app gets standing delegated access without ever stealing a credential. Restrict user consent to verified publishers and low-risk scopes, and require admin consent for anything that reads broadly.

Validating tokens correctly — the security boundary

This is where APIs get compromised. Decoding a token proves nothing; validating it is the entire boundary. A correct validator checks all of the following, and every check is load-bearing — skip one and there is a concrete attack:

  1. Signature against the tenant’s published JWKS (defeats forgery).
  2. Issuer (iss) matches your tenant’s expected issuer exactly, minding v1.0 sts.windows.net vs v2.0 login.microsoftonline.com/.../v2.0 (defeats tokens from other authorities).
  3. Audience (aud) equals your API’s app ID URI or client ID — not Graph’s 00000003-0000-0000-c000-000000000000 (defeats accepting a token minted for a different resource).
  4. Expiry / not-before (exp, nbf) with small clock skew (defeats stale/replayed tokens).
  5. Authorization: a required scp value for delegated calls, or a required roles value for app-only calls (enforces least privilege).
  6. For ID tokens (client side only): the nonce matches what the client sent (defeats ID-token replay).

What each check defends against, made explicit:

Check Claim(s) Attack if you skip it How Entra/libs help
Signature alg, kid + JWKS Forged token accepted JWKS from OIDC discovery; kid selection
Issuer iss Token from a rogue/other tenant accepted Pin authority; multi-tenant uses issuer validator
Audience aud Token for Graph or another API accepted Set Audience to your API
Expiry / nbf exp, nbf, iat Stale/replayed token accepted Default validation with skew
Scope/role scp / roles Under-authorized caller allowed RequireScope / RequireRole
Nonce (ID token) nonce ID-token replay to establish a session MSAL/OIDC middleware validates it

The signing keys rotate, and you must not pin a key or cache JWKS forever. Pull keys from OIDC discovery and let the library refresh on rollover. In ASP.NET Core, Microsoft.Identity.Web handles discovery, JWKS caching, and kid-based key selection for you:

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
// appsettings.json
{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "72f988bf-86f1-41af-91ab-2d7cd011db47",
    "ClientId": "api://kv-orders-api",
    "Audience": "api://kv-orders-api"
  }
}

For a manual JwtBearer setup, never hardcode IssuerSigningKeys. Let the handler fetch the OIDC metadata document (/.well-known/openid-configuration under your tenant authority); it discovers the jwks_uri, caches keys, and re-fetches when an unknown kid appears — exactly what handles rollover gracefully:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://login.microsoftonline.com/72f988bf-.../v2.0";
        o.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = "https://login.microsoftonline.com/72f988bf-.../v2.0",
            ValidateAudience = true,
            ValidAudience = "api://kv-orders-api",
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(5),
            // DO NOT set IssuerSigningKeys — the handler pulls JWKS from Authority metadata
            RequireSignedTokens = true,
            ValidAlgorithms = new[] { "RS256", "PS256" }   // never accept "none"
        };
    });

Enforce authorization with policies, not ad hoc string checks:

builder.Services.AddAuthorization(o =>
{
    o.AddPolicy("OrdersAdmin", p => p.RequireRole("Orders.Admin"));
    o.AddPolicy("CanReadOrders", p =>
        p.RequireAssertion(ctx =>
            ctx.User.HasClaim(c => c.Type == "http://schemas.microsoft.com/identity/claims/scope"
                                   && c.Value.Split(' ').Contains("Orders.Read"))
            || ctx.User.IsInRole("Orders.Admin")));
});

RequireRole reads the roles claim. scp is space-delimited in one claim, so a substring check is wrong — Orders.Read matches Orders.ReadBasic. RequireScope/[RequiredScope] from Microsoft.Identity.Web splits and compares correctly; roll your own only if you split on spaces first.

If your API sits behind Azure API Management, do the same validation at the gateway with validate-jwt before the request reaches your backend — belt and suspenders, and it offloads the check:

<validate-jwt header-name="Authorization" failed-validation-httpcode="401"
              require-scheme="Bearer" output-token-variable-name="jwt">
  <openid-config url="https://login.microsoftonline.com/72f988bf-.../v2.0/.well-known/openid-configuration" />
  <audiences>
    <audience>api://kv-orders-api</audience>
  </audiences>
  <issuers>
    <issuer>https://login.microsoftonline.com/72f988bf-.../v2.0</issuer>
  </issuers>
  <required-claims>
    <claim name="roles" match="any">
      <value>Orders.Admin</value>
    </claim>
  </required-claims>
</validate-jwt>

The APIM policy details are in APIM Policies From Scratch: Rate-Limit-By-Key, JWT Validation and Request/Response Transformation.

Customizing tokens: optional claims and claims mapping

Two distinct mechanisms, constantly confused:

Add an optional email claim to the access and ID tokens:

az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body '{
    "optionalClaims": {
      "accessToken": [{ "name": "email" }, { "name": "acct" }],
      "idToken":     [{ "name": "email" }, { "name": "upn" }]
    }
  }'

The mechanisms side by side, so you pick the safe one by default:

Aspect Optional claims Claims mapping policy
Source Entra-known attributes Directory schema / extension attributes / transforms
Attached to Application optionalClaims Service principal (policy assignment)
Extra guard needed None acceptMappedClaims or custom signing key
Spoofing surface Low (Entra-owned) Higher (arbitrary data)
Typical use Add email, upn, acct, ipaddr Map an extension attribute a legacy app needs
Rotation concern None Custom signing key must be rotated

Common optional claims worth knowing:

Claim Adds Note
email User’s email May be empty if not populated
upn User principal name Human-readable identifier
acct Account status (0 member / 1 guest) Guest vs member decisions
ipaddr Caller IP Useful for logging; not a security gate
groups (as optionalClaims with format) Group ids in a chosen format Still overflows; prefer roles
xms_pdl Preferred data location Multi-geo scenarios

Optional claims are best-effort: if the source attribute is empty for a user, the claim simply does not appear. Never make a claim’s presence a security gate unless you have verified it is always populated — an absent email should not silently downgrade authorization.

Token lifetime and Continuous Access Evaluation

Access tokens default to roughly 60–90 minutes (Entra randomizes within a window to avoid thundering-herd renewals). The old Configurable Token Lifetime (CTL) knobs that let you shorten access-token lifetime were retired for access/ID tokens — you cannot simply set a 5-minute access token anymore. Microsoft’s answer to “I need faster revocation than token expiry” is Continuous Access Evaluation (CAE).

CAE lets a CAE-capable resource (Microsoft Graph, and your own API if you implement it) subscribe to critical Entra events and reject a still-valid token in near real time when something changes. Instead of the client presenting a token that Entra already knows is stale, the resource returns a 401 with a claims challenge, the client goes back to Entra, and Entra either re-issues or blocks. This closes the “fired employee has a valid token for 55 more minutes” gap for the events CAE covers.

What CAE revokes in near real time versus what still only expires:

Event Revoked by CAE (near real time)? Otherwise
User account disabled/deleted Yes Waits for token expiry
User password reset/changed Yes Waits for expiry
Admin explicitly revokes sessions Yes Waits for expiry
High user risk detected (Identity Protection) Yes Waits for expiry
Network location change (CA IP-based policy) Yes (token bound to location) Waits for expiry
A new group membership you rely on No (claims are minted at issuance) New token on next renewal
A new app-role assignment No New token on next renewal
Consent revoked mid-session Partially / on renewal Next token acquisition

Two practical consequences. First, authorization changes are not instantaneous even with CAE — adding a role or group does not rewrite tokens already issued; it takes effect on the next token the client acquires (or immediately on a forced re-auth). Second, to get CAE’s revocation on your API you must implement CAE support (advertise the capability, honor the claims challenge) — you inherit it for free on Graph, not on your own endpoints.

CAE readiness at a glance:

To get CAE benefit on… You must Result
Microsoft Graph Use a CAE-capable client (MSAL, opt-in) Near-real-time revocation for the events above
Your own API Implement CAE (declare capability, return claims challenges) Same benefits for your resource
Legacy client that ignores challenges (nothing works) Falls back to token expiry only

The token-lifetime reality, so nobody chases a retired knob:

Token Default lifetime Configurable? Faster revocation via
Access token ~60–90 min (randomized) No (CTL retired for access tokens) CAE
ID token ~60–90 min No Re-auth
Refresh token up to 90 days inactivity (rolling) Via CA sign-in frequency / session controls CA session policy, CAE
SAML token ~1 hour Limited Re-auth

Architecture at a glance

Picture the full multi-tier request as it actually flows, and hold the token audience in your head at each hop — that single fact is the through-line of the entire design.

A user signs in at the browser. The SPA/frontend completes an authorization-code + PKCE flow against Entra ID and receives two tokens: an ID token (aud = the SPA’s own client ID) that stays on the client to establish the session, and an access token A (aud = api://kv-orders-api, carrying scp: Orders.Read) that it attaches as a bearer token when it calls your API. The Orders API — the middle tier — does the security-critical work first: it validates token A’s signature against the tenant JWKS, pins iss to the tenant and aud to itself, checks exp/nbf, and authorizes on scp (user present) or roles. Only then does it act. When it needs to read the caller’s profile or groups from Microsoft Graph, it does not forward token A — it performs an On-Behalf-Of exchange at the token endpoint, presenting its own certificate/federated-credential assertion plus token A as the user assertion, and receives access token B (aud = https://graph.microsoft.com, still as the user). Graph consumes token B and returns data. A separate path shows a daemon using the client-credentials grant to obtain an app-only token (idtyp: app, roles: Orders.Process, no scp, no user) to call the same API — authorized purely on roles.

Trace the audience left to right and every rule falls out of it: the ID token never leaves the client because its audience is the client; the API accepts only tokens whose audience is the API and rejects a Graph token outright; OBO exists precisely because you cannot cross an audience boundary by forwarding; and the daemon path is authorized on roles because there is no user and therefore no scp. Underneath every hop sits the same non-negotiable footer: validate signature (JWKS) → pin issuer → pin audience → check expiry → authorize on scp/roles. Localize any failure to a hop, decode the token at that hop, and the wrong-audience/missing-scope/unconsented-permission cause is immediately visible.

Real-world scenario

Meridian Payments runs an Orders API on Azure App Service (.NET 8, Microsoft.Identity.Web) fronted by a React SPA. The API calls Microsoft Graph via OBO to read the caller’s group memberships for fine-grained authorization — regional managers may approve orders in their region, and region is derived from a set of security groups. The platform team is five engineers; the tenant has ~14,000 users. It passed every test in dev and staging and broke in production for exactly the regional managers — the people with the broadest access.

The incident opened on a Monday. Three regional managers reported 403 on the approvals screen; everyone else worked. The on-call engineer’s first instinct was an RBAC bug in the app and spent forty minutes reading the authorization policy, which was correct. The breakthrough came from decoding an affected user’s token instead of reading code. Token A validated fine — right aud, right iss, scp: Orders.Read Orders.Approve. But there was no groups claim; instead the token carried _claim_names.groups pointing at getMemberObjects. Those managers each sat in 300+ security groups (entitlement-management churn had ballooned their memberships), so Entra dropped the groups claim past the ~150 JWT cap and emitted the overage pointer. The validator treated “no groups claim” as “no groups” and denied. In dev, test users had five groups and never overflowed — the bug was invisible below the cap.

The first fix attempt made it worse. A developer added a Graph call to resolve overage using GroupMember.Read.All — an admin-consent-only application-scale permission the security team refused to grant tenant-wide, because it would let the API read every group in the directory. The OBO call returned AADSTS65001 (consent not granted), and the “fix” now failed for everyone, not just managers.

The real fix had two parts. First, they stopped relying on raw group claims for the tier decision and switched to app roles plus groupMembershipClaims: "ApplicationGroup", so the token carried only groups assigned to the app (a bounded set that never overflows) and the coarse Orders.RegionalApprover role came from a stable roles claim. Second, for the residual region-specific groups they still needed, they handled overage by calling transitiveMemberOf with the user’s own OBO token under the already-consented User.Read — filtering server-side by the specific group ids they cared about, instead of demanding GroupMember.Read.All.

// Resolve the required approval groups honoring overage, using the user's OBO token (User.Read scope)
if (principal.HasClaim(c => c.Type == "_claim_names"))
{
    var groups = await graph.Me.TransitiveMemberOf.GraphGroup
        .GetAsync(r => r.QueryParameters.Select = new[] { "id" });
    // Match the caller's region group from `groups` — never from the absent `groups` claim
}

The result: approvals worked for every manager regardless of group count, the API held only least-privilege Graph consent (User.Read), and the tier check no longer depended on a claim that could vanish. The lesson written on the wall: never let the absence of a claim mean “deny by default” without first checking for the overage indicator — and never solve an overage by escalating to a directory-wide permission.

The incident as a timeline, because the order of moves is the lesson:

Time Symptom Action taken Effect What it should have been
09:12 3 managers 403, others fine (alert fires) Decode an affected token first
09:15 Same 403s Read the authz policy Policy is correct; 40 min lost Don’t debug code before the token
09:55 Root cause suspected Decode token: no groups, has _claim_names Overage identified This was the breakthrough
10:10 Attempted fix Add Graph call w/ GroupMember.Read.All AADSTS65001, now everyone fails Don’t escalate to directory-wide scope
10:40 Correct approach App roles + ApplicationGroup + overage via User.Read 403s clear, least privilege kept The actual fix
+1 day Hardened Unit tests for overage + wrong-audience Regression-proofed The cheapest tests to write

Advantages and disadvantages

Entra ID’s token model — audience-scoped tokens, distinct primitives for delegated and application authorization, and OBO for delegation across tiers — is powerful precisely because it forces boundaries, and it is fiddly for the same reason. Weigh it honestly:

Advantages (why this model helps you) Disadvantages (why it bites)
Audience-scoped tokens make “who is this token for?” explicit — a token for Graph physically cannot be a token for you (if you validate aud) The audience boundary means you must use OBO to cross tiers; you cannot just forward a token
scp vs roles cleanly separates “acting for a user” from “acting as a service” — daemons and users are unambiguous The distinction is invisible until an app-only caller appears with no scp; easy to authorize wrong
App roles give stable, non-overflowing coarse RBAC minted into the token — no directory query at runtime Nested-group members do not inherit app roles; a nesting assumption silently denies
Group claims are convenient for fine-grained authz when the set is small Group claims overflow silently past ~150/200; absence-as-deny is a real incident
JWKS + OIDC discovery means signing-key rollover is handled by the library A hand-rolled validator that pins a key dies on the next rollover, taking every call down
OBO with certificates/FIC removes long-lived secrets from the middle tier entirely OBO has three preconditions (audience, downstream permission, consent); miss one and it’s an opaque AADSTS code
Optional claims add identity data safely without policy overhead Claims mapping policies (for non-standard data) require acceptMappedClaims/custom keys and are a spoofing surface
CAE revokes disabled/risky sessions in near real time on Graph CAE doesn’t propagate authorization changes (new roles/groups) instantly; those wait for renewal

The model is right for any API on Entra ID that needs real, boundaried delegation — especially multi-tier apps touching Graph, and mixed user/daemon APIs. It bites hardest on teams that treat the token as opaque, cache signing keys, substring-match scp, or read a missing groups claim as “no groups.” Every disadvantage is manageable, but only if you know it exists — which is the whole point.

Hands-on lab

Register an API with a scope and an app role, mint a real token, decode it, confirm the claims, and prove the authorization boundary with 401/403/200. Free of charge — this uses only Entra ID and az. Run in Cloud Shell (Bash) signed in to a tenant where you can create app registrations.

Step 1 — Variables.

APINAME="kv-orders-api-lab-$RANDOM"
TENANT_ID=$(az account show --query tenantId -o tsv)
echo "Tenant: $TENANT_ID  API: $APINAME"

Step 2 — Create the API app registration and set its identifier URI + token version.

az ad app create --display-name "$APINAME" -o none
APP_OBJECT_ID=$(az ad app list --display-name "$APINAME" --query "[0].id" -o tsv)
APP_CLIENT_ID=$(az ad app list --display-name "$APINAME" --query "[0].appId" -o tsv)

az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body "{ \"identifierUris\": [\"api://$APP_CLIENT_ID\"], \"api\": { \"requestedAccessTokenVersion\": 2 } }"

Expected: no error; the app now has api://<clientId> as its identifier and issues v2.0 tokens.

Step 3 — Add a delegated scope Orders.Read and an app role Orders.Admin.

SCOPE_ID=$(uuidgen); ROLE_ID=$(uuidgen)
az rest --method PATCH \
  --uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
  --headers "Content-Type=application/json" \
  --body "{
    \"api\": { \"oauth2PermissionScopes\": [{
      \"id\": \"$SCOPE_ID\", \"value\": \"Orders.Read\", \"type\": \"User\", \"isEnabled\": true,
      \"adminConsentDisplayName\": \"Read orders\", \"adminConsentDescription\": \"Read orders on behalf of the user.\"
    }] },
    \"appRoles\": [{
      \"id\": \"$ROLE_ID\", \"value\": \"Orders.Admin\", \"displayName\": \"Orders Administrator\",
      \"description\": \"Full access to orders.\", \"allowedMemberTypes\": [\"User\"], \"isEnabled\": true
    }]
  }"

Expected: no error. Verify:

az ad app show --id "$APP_CLIENT_ID" \
  --query "{scopes:api.oauth2PermissionScopes[].value, roles:appRoles[].value}" -o json
# → {"scopes":["Orders.Read"],"roles":["Orders.Admin"]}

Step 4 — Create the service principal and assign yourself the Orders.Admin role.

az ad sp create --id "$APP_CLIENT_ID" -o none
RESOURCE_SP=$(az ad sp list --display-name "$APINAME" --query "[0].id" -o tsv)
MY_OID=$(az ad signed-in-user show --query id -o tsv)

az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$RESOURCE_SP/appRoleAssignedTo" \
  --headers "Content-Type=application/json" \
  --body "{ \"principalId\": \"$MY_OID\", \"resourceId\": \"$RESOURCE_SP\", \"appRoleId\": \"$ROLE_ID\" }"

Expected: an assignment object returns. Confirm:

az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$RESOURCE_SP/appRoleAssignedTo" \
  --query "value[].{principal:principalDisplayName, role:appRoleId}" -o table

Step 5 — Mint a real access token for your API’s scope and decode it.

TOKEN=$(az account get-access-token --scope "api://$APP_CLIENT_ID/Orders.Read" \
  --query accessToken -o tsv)

# Decode the payload (base64url) locally — never send it anywhere
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | \
  awk '{ l=length($0)%4; if(l==2)$0=$0"=="; else if(l==3)$0=$0"="; print }' | \
  base64 -d 2>/dev/null | jq '{aud, iss, ver, scp, roles, azp, oid}'

Expected payload shape:

{
  "aud": "api://<clientId>",
  "iss": "https://login.microsoftonline.com/<tenant-id>/v2.0",
  "ver": "2.0",
  "scp": "Orders.Read",
  "roles": ["Orders.Admin"],
  "azp": "<the Azure CLI client id>",
  "oid": "<your object id>"
}

Step 6 — Read the header and confirm the algorithm and key id.

echo "$TOKEN" | cut -d. -f1 | tr '_-' '/+' | \
  awk '{ l=length($0)%4; if(l==2)$0=$0"=="; else if(l==3)$0=$0"="; print }' | \
  base64 -d 2>/dev/null | jq '{alg, kid, typ}'
# → { "alg": "RS256", "kid": "...", "typ": "JWT" }

Validation checklist. You proved the whole model without writing an API: a scope surfaces in scp only for a delegated (user) token; an app role assigned to you surfaces in roles; aud is your API (not Graph); iss ends in /v2.0; and the header names RS256 with a kid that a validator would use to pick the JWKS key. The lab steps mapped to what each proves:

Step What you did What it proves
2 Set identifierUris + v2 The aud your API validates and the token version it issues
3 Define scope + role The two authorization primitives exist independently
4 Assign the role to yourself Assignments live on the service principal, not the app
5 Mint + decode token scp and roles appear exactly as designed; aud/iss correct
6 Read the header RS256 + kid are what signature validation keys off

Cleanup.

az ad app delete --id "$APP_CLIENT_ID"     # removes the app + its service principal + assignments

Cost note. App registrations, service principals, role assignments, and az account get-access-token are all free — this lab costs nothing.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. First a scannable symptom→cause→confirm→fix table, then the expanded reasoning for the entries that bite hardest, then the AADSTS reference.

# Symptom Root cause Confirm (exact cmd / path) Fix
1 API accepts a token it should reject No audience validation Decode token: aud is Graph 00000003-... or another API, yet API accepted it Set Audience/ValidAudience to your API; reject others
2 Delegated endpoint 403, scp looks present Substring scp check (Orders.ReadOrders.ReadBasic) Log the raw scp string; note the near-match Split on space; use RequireScope/[RequiredScope]
3 Daemon gets 403, empty roles No Application app-role assigned (or not admin-consented) Decode: idtyp: app, no roles; check appRoleAssignedTo Assign an Application role to the client SP; admin consent
4 Power users 403 on group-based authz Group overagegroups dropped Decode: _claim_names.groups present, no groups Handle overage (Graph transitiveMemberOf); prefer app roles
5 Every call fails after weeks of working Pinned signing key broke on rollover Validator logs “unable to find signing key”/kid mismatch Remove pinned keys; use OIDC discovery/Authority
6 OBO returns AADSTS65001 Downstream permission not consented Enterprise app → Permissions; scope ungranted az ad app permission admin-consent; grant the scope
7 OBO invalid_grant “assertion audience” Incoming token aud ≠ your API Decode A: aud is Graph/other Frontend must request api://your-api/scope, not Graph
8 401 with a claims challenge on OBO CA/MFA/step-up required downstream (AADSTS50076) Response body has claims param Surface the challenge to the client to re-auth interactively
9 ID token accepted by the API Validator does not distinguish token types Decode: aud is the client id, no scp/roles Validate access tokens only; pin aud to the API
10 User signs in but has no roles appRoleAssignmentRequired off + unassigned az ad sp show --query appRoleAssignmentRequired; check assignment Assign the role; set appRoleAssignmentRequired: true
11 roles absent for a user in a nested group Nested groups don’t grant app roles The user is a member of a child of the assigned group Assign the role to the group the user is a direct member of
12 Wrong-tenant token accepted (multi-tenant) iss/tid not validated against an allow-list Decode: tid/iss is a foreign tenant Validate issuer per-tenant; allow-list tid
13 Optional claim (email) missing, breaks logic Claim is best-effort; source empty Decode: no email; user’s attribute empty in directory Don’t gate on presence; populate the attribute or use oid
14 AADSTS700016 app not found in tenant Resource SP not provisioned in the tenant az ad sp list --display-name empty in that tenant az ad sp create --id <appId> in the tenant

The expanded form for the entries that cost the most time:

1. Your API accepts a token it should reject (wrong audience). Root cause: The validator checks the signature and issuer but not the audience, so a token minted for Microsoft Graph (or any other resource in your tenant) passes. Confirm: Decode an accepted token and read aud — if it is 00000003-0000-0000-c000-000000000000 (Graph) or another API’s identifier and your API still processed the request, audience validation is off. Fix: Set Audience/ValidAudience to your API’s app ID URI or client ID and reject anything else. This is the single most important validation check — without it, any principal that can obtain a Graph token can call your API.

2. A delegated endpoint returns 403 even though scp “contains” the scope. Root cause: A substring check — scp.Contains("Orders.Read") is true for a token that only has Orders.ReadBasic, so you either over-grant or (with reversed logic) under-grant. Confirm: Log the raw scp string; you will see the near-miss value. Fix: scp is one space-delimited string; split on spaces and compare whole tokens. Use Microsoft.Identity.Web’s [RequiredScope]/RequireScope, which does this correctly.

3. A daemon gets 403 with an empty roles claim. Root cause: No Application app role was assigned to the daemon’s service principal (or the assignment was never admin-consented), so its app-only token carries no roles. Confirm: Decode the token — idtyp: app, and roles is absent. Check GET /servicePrincipals/{resourceSp}/appRoleAssignedTo for the client SP. Fix: Assign an allowedMemberTypes: ["Application"] role to the client’s SP with resourceId = your API’s SP, and admin-consent it.

4. Group-based authorization denies exactly your highest-privilege users. Root cause: Group-claims overage — those users exceed the ~150 (JWT) / ~200 (SAML) cap, so Entra drops groups and emits _claim_names/_claim_sources. Code that reads a missing groups as “no groups” denies them. Confirm: Decode an affected token — _claim_names.groups (or hasgroups: true) with no groups. Fix: Detect overage and call Graph (transitiveMemberOf/getMemberObjects, securityEnabledOnly: true) with an OBO token under a least-privilege scope; better, move the decision to app roles, which don’t overflow.

5. Everything fails after weeks of working (signing-key rollover). Root cause: A hand-rolled validator pinned the signing key or cached JWKS forever; Entra rotated keys and the new kid no longer matches anything cached. Confirm: Validator logs an “unable to find a signing key that matches”/kid error; the token itself is valid. Fix: Never pin keys. Point the handler at your tenant Authority (OIDC discovery) so it fetches and refreshes JWKS and re-fetches on an unknown kid.

6–8. The OBO trio. AADSTS65001 means the downstream permission is not consented — grant admin consent for your middle tier’s Graph permission. invalid_grant with an assertion-audience complaint means the incoming token’s aud is not your API; the frontend must request api://your-api/scope. A 401 carrying a claims challenge (AADSTS50076/50079) means the downstream needs step-up (MFA/CA); propagate the challenge to the client to re-authenticate interactively.

The AADSTS error reference

AADSTS codes are issued by Entra during token acquisition and land in the sign-in logs. The ones you will actually meet, straight to cause and fix:

Code Meaning Likely cause Fix
AADSTS65001 Consent not granted Client/API lacks consent for the requested permission Grant admin consent for the scope/app role
AADSTS70011 Invalid/unknown scope Scope string typo or wrong resource identifier Correct the scope; use api://…/Name or /.default
AADSTS50105 User not assigned a required app role appRoleAssignmentRequired on + user unassigned Create the appRoleAssignedTo assignment
AADSTS50076 Interaction required (MFA) CA/MFA needed for the resource Return the claims challenge; client re-auths
AADSTS50079 Strong-auth enrollment required User must register MFA Complete MFA registration
AADSTS500131 Assertion audience mismatch (OBO) Incoming token aud ≠ your API Request a token for your API, not Graph
AADSTS700016 App not found in tenant Resource SP not provisioned az ad sp create --id <appId> in the tenant
AADSTS700051 RSC / requested resource mismatch Wrong resource in the request Align resource/scope with the app
AADSTS7000215 Invalid client secret Wrong/expired secret on the client Rotate secret; prefer certificate/FIC
AADSTS9002313 Invalid request / malformed Bad grant/parameter Fix the request body (grant_type, scope)
AADSTS50013 Assertion validation failed Expired/malformed assertion token Refresh the assertion (incoming token)
AADSTS90002 Tenant not found Wrong tenant id in the authority Correct the authority/tenant

Pull the sign-in logs by app to correlate what the client saw with what Entra recorded:

az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/auditLogs/signIns?\$filter=appId eq '$API_CLIENT_ID'&\$top=20" \
  --query "value[].{time:createdDateTime, user:userPrincipalName, code:status.errorCode, reason:status.failureReason}" \
  -o table

Sign-in logs only show what reached Entra. A 403 your API returned is a code decision, not a directory event — it will not appear there. So if the token acquired cleanly (no AADSTS in the logs) but the caller still got 403, the fault is in your authorization logic and the decoded roles/scp, not the directory. Decoding the token tells you which side to look at.

Tell a platform failure from an application failure — this fork saves the most time:

Signal Platform (Entra) failure Application (your code) failure
Where it shows Sign-in logs, AADSTS code Your API logs, HTTP 401/403
Token acquired? No (blocked at Entra) Yes (valid token in hand)
Typical cause Consent, assignment, MFA, wrong scope Wrong-audience accept, scp/roles check, overage
First move Read the AADSTS code Decode the token; read aud/scp/roles

Best practices

Security notes

The security controls mapped to the threat each one blunts:

Control Mechanism Threat it blunts
Audience pinning ValidAudience = your API Accepting a token for another resource
Issuer/tenant pinning ValidIssuer / tid allow-list Tokens from a rogue/other tenant
Signature via JWKS OIDC discovery + kid Forged tokens; alg: none
Least-privilege consent Narrow Graph scopes Directory-wide data exfiltration
Cert/FIC for OBO client_assertion Middle-tier secret theft → impersonation
CAE Near-real-time revocation Fired/compromised user with a live token
Absence-safe authorization Overage handling; presence ≠ grant Silent denial / bypass on missing claims

Cost & sizing

Entra ID’s token machinery is largely free — app registrations, service principals, app-role assignments, token issuance, OBO exchanges, and JWKS/OIDC discovery cost nothing per call. The cost dimensions are indirect: the Entra ID licensing tier for certain features, the compute that runs your API and does validation, and the operational cost of getting it wrong.

Rough figures for a typical multi-tier app on Entra ID:

Cost driver What you pay for Rough figure Notes
App registrations / SPs / assignments Nothing ₹0 Free at any scale
Token issuance / OBO / JWKS Nothing ₹0 Per-call free
Entra ID P1 (per user/month) CA, dynamic/group-based authz inputs ~₹500–650 Needed for CA-driven step-up, dynamic groups
Entra ID P2 (per user/month) Identity Protection risk, PIM ~₹750–1,000 For risk-based CAE, JIT roles
API compute (validation) Negligible extra CPU included in your App Service/AKS bill Cache JWKS; validation is cheap
OBO without caching Extra token-endpoint calls + latency avoidable Use MSAL caching; near-zero if done right
Graph throttling risk Backoff/retry overhead avoidable Cache group lookups; don’t call Graph per request

Sizing guidance: validation cost is flat and trivial, so size your API for its business load, not for auth. The only auth-driven capacity concern is not caching OBO/Graph — fix that in code and the token layer disappears from your capacity plan. For licensing, buy P1 for Conditional Access-driven step-up or dynamic/group-based authorization; buy P2 for risk-based CAE and PIM on privileged paths.

Interview & exam questions

1. What is the difference between an ID token and an access token, and which does an API validate? The ID token proves the user authenticated and its audience is the client — it stays on the client to establish a session and carries no scp/roles. The access token is a credential whose audience is the resource; it is the only token an API validates and authorizes on. An API that accepts an ID token has a hole, because the ID token’s audience is the client and it can be replayed to a validator that only checks signature and tenant.

2. An API validates the signature and issuer of every token but still gets compromised. What is missing? Audience validation. Without pinning aud to the API, a token minted for Microsoft Graph (or any other resource in the tenant) passes signature+issuer checks and is accepted — so any principal that can get a Graph token can call the API. Fix by setting ValidAudience/Audience to the API’s identifier and rejecting all others.

3. What is the difference between scp and roles, and how do you know a call has no user? scp (space-delimited) appears when a user is present and delegated authority to a client; the effective permission is the intersection of the app’s grant and the user’s rights. roles appears either for a user/group-assigned app role (delegated) or for an application-assigned role (app-only). You know there is no user when the token has idtyp: app (or has no delegated scp and was obtained via client credentials) — authorize such calls on roles only.

4. Explain the On-Behalf-Of flow and its three preconditions. OBO exchanges an incoming access token A (aud = your API) for a new token B (aud = a downstream like Graph) at the token endpoint, presenting A as the user assertion plus the middle tier’s own credential. The three preconditions: (a) A’s audience must be your API and it must carry a scope you exposed; (b) your API’s registration must hold the downstream delegated permission, admin-consented; © the user or an admin must have consented to that permission. Missing consent → AADSTS65001; wrong audience → invalid_grant/AADSTS500131.

5. Why does group-based authorization sometimes fail for exactly your most privileged users, and how do you fix it? Those users belong to more groups than the token cap (~150 JWT / ~200 SAML), so Entra drops the groups claim and emits an overage pointer (_claim_names/_claim_sources). Code that reads a missing groups as “no groups” denies them. Fix by detecting overage and calling Graph (transitiveMemberOf/getMemberObjects) with a least-privilege OBO scope — or, better, switch the decision to app roles, which do not overflow.

6. Your API worked for weeks then rejected every token overnight. Most likely cause? A hand-rolled validator pinned the signing key or cached JWKS indefinitely, and Entra rotated its signing keys — the new kid matches nothing cached. Fix by removing pinned keys and pointing the handler at your tenant Authority so it fetches JWKS from OIDC discovery and refreshes on an unknown kid.

7. How do app roles get assigned, and why do nested-group members sometimes not get one? App roles are defined on the application object but assigned on the resource service principal (appRoleAssignedTo). Assignment can target a user, a group, or (for Application roles) another app’s SP. Nested groups do not transitively grant app roles — only direct members of an assigned group receive the role in their token — so a user in a child group of the assigned parent gets nothing.

8. What is appRoleAssignmentRequired and what changes when you turn it on? It is a flag on the resource SP. With it off (default), any user can authenticate and simply receives no roles claim if unassigned; your API then 403s them but they still obtained a valid token. With it on, Entra blocks sign-in for unassigned users (AADSTS50105) — making app-role assignment the actual access gate rather than just a claim.

9. Why can’t you just set a 5-minute access-token lifetime for faster revocation, and what do you use instead? The Configurable Token Lifetime knobs for access/ID tokens were retired; access-token lifetime is ~60–90 min and not arbitrarily shortened. For near-real-time revocation you use Continuous Access Evaluation (CAE), where a CAE-capable resource rejects a still-valid token on critical events (user disabled, password reset, high risk, admin revocation) via a claims challenge. Note CAE revokes sessions, not authorization changes — new roles/groups still take effect at the next token acquisition.

10. What is the difference between optional claims and a claims mapping policy? Optional claims add Entra-known claims (email, upn, acct) via the app’s optionalClaims, with no policy overhead and low risk because Entra owns the data. A claims mapping policy sources or transforms claims from directory schema/extension attributes, is attached to a service principal, and — because it can map arbitrary data into a token — requires acceptMappedClaims or a custom signing key as a spoofing guard. Prefer optional claims where they suffice.

11. A caller acquires a token cleanly (no AADSTS in sign-in logs) but your API still returns 403. Where is the fault? In your application’s authorization logic, not the directory. Sign-in logs only record what reached Entra; a 403 your code returns is a policy decision that never appears there. Decode the token and inspect aud, scp, and roles — the cause is typically a substring scp check, a missing role, an unhandled group overage, or (worst) an audience you should have rejected.

12. How should a middle tier authenticate for OBO, and why not a client secret? With a certificate or workload identity federation (federated credential), supplied as client_assertion. A client secret is a long-lived bearer credential; if it leaks, the holder can perform OBO for any user who ever called the API — i.e. impersonate the entire user base. A certificate/FIC removes or hardens that credential and is the production standard.

These map primarily to SC-300 (Identity and Access Administrator) — app registrations, permissions/consent, token configuration, Conditional Access — and to AZ-204 (Developer Associate) for implementing authentication with MSAL, OBO, and Microsoft Graph. The consent-governance and privileged-access angles touch SC-100 (Cybersecurity Architect). A compact cert map:

Question theme Primary cert Objective area
ID vs access vs refresh; JWT claims AZ-204 / SC-300 Implement auth; secure apps
Token validation (aud/iss/JWKS) AZ-204 Implement secure APIs with MSAL
App roles vs scopes vs groups SC-300 Manage app access & permissions
OBO + Graph AZ-204 Develop apps that use Microsoft Graph
Consent governance SC-300 / SC-100 Manage consent & permissions
CAE & Conditional Access SC-300 Implement access management

Quick check

  1. An API validates a token’s signature and confirms it came from your tenant, then processes the request. What critical check is it missing, and what token would sneak through?
  2. A daemon calls your API and gets 403 with an empty roles claim. Name the two things that must be true for its app-only token to carry a role.
  3. A user in 300 security groups is denied on group-based authorization while everyone else works. What is in the token instead of groups, and what is the correct fix?
  4. Your OBO call returns AADSTS65001. What precondition failed, and how do you fix it?
  5. True or false: to revoke a fired employee’s access within minutes, you configure a 5-minute access-token lifetime.

Answers

  1. It is missing audience validation. A token minted for Microsoft Graph (or any other resource in the tenant) has a valid signature and your tenant’s issuer, so it passes both checks and is wrongly accepted. Pin aud (ValidAudience) to your API and reject everything else.
  2. First, the app role must be defined with allowedMemberTypes: ["Application"] on the API’s application object. Second, that role must be assigned to the daemon’s service principal (appRoleAssignedTo with resourceId = your API’s SP) and admin-consented. Only then does the app-only token carry roles.
  3. The token carries an overage indicator_claim_names.groups / _claim_sources (or hasgroups: true) — and no groups claim, because the user exceeded the ~150 (JWT) cap. The fix is to detect overage and resolve groups via Graph (transitiveMemberOf/getMemberObjects) with a least-privilege OBO scope, or better, move the decision to app roles, which do not overflow. Never read a missing groups claim as “no groups.”
  4. Consent for the downstream permission was not granted. Your middle tier’s registration must request the downstream (e.g. Graph User.Read) delegated permission, and it must be consented in the tenant. Grant admin consent (az ad app permission admin-consent --id <apiClientId>) or have the user consent interactively.
  5. False. Access-token lifetime is not arbitrarily shortenable (the CTL knobs for access tokens were retired). You use Continuous Access Evaluation (CAE), which revokes a still-valid session in near real time when the account is disabled/reset/risky — no short lifetime required.

Glossary

Next steps

You can now read a token claim by claim, validate it the way an internet-facing API must, choose the right authorization primitive, and wire OBO with its preconditions met. Build outward:

Entra IDOAuth2OIDCJWTApp RolesOn-Behalf-OfToken ValidationMicrosoft Graph
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading