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:
- Distinguish an ID token, access token, and refresh token by purpose, audience, lifetime, and who is allowed to read each — and explain why an API must validate the access token and ignore the ID token.
- Read a v1.0 and v2.0 JWT end to end: decode the header (
alg,kid,typ), and interpret every authorization-relevant claim (aud,iss,scp,roles,azp/appid,oid,sub,tid,idtyp,nonce,wids,groups,hasgroups). - Validate a token correctly: verify the signature against the tenant’s JWKS, pin issuer and audience to this tenant and this API, check
exp/nbfwith bounded clock skew, and authorize onscporroles— and explain why every one of those steps is load-bearing. - Choose deliberately between delegated scopes, app roles (assigned to users/groups or to applications), and group claims for any authorization requirement, and enumerate the trade-offs of each.
- Handle the group-claims overage (
_claim_names/_claim_sources) so authorization never breaks for users in many groups. - Implement the On-Behalf-Of flow correctly with MSAL, enumerate its three preconditions, and map its failures (
AADSTS65001,AADSTS50076,invalid_grant) to fixes. - Add optional claims and understand when a claims mapping policy (with
acceptMappedClaimsor a custom signing key) is required and why it is a spoofing surface. - Reason about token lifetime and Continuous Access Evaluation (CAE), and know what CAE revokes in near real time versus what only expires.
- Drive the diagnostic path: decode the token, tell a platform 403 from an app 403, pull the sign-in logs by app, and read
AADSTScodes straight to their fix.
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.
- Delegated permissions (scopes,
scp) answer: what is this client allowed to do on behalf of the signed-in user? The effective permission is the intersection of what the client app is granted and what the user can do. Scopes are the right model when the user’s own rights matter — a mailbox API where the client should never exceed what the user could do directly. - App roles (
roles) answer: what is this principal allowed to do? App roles are assigned to users/groups (surfacing in delegated tokens asroles) or to applications (for daemons,allowedMemberTypes: ["Application"], surfacing in app-only tokens). Roles are the right model for coarse RBAC tiers (Admin/Reader) and for machine-to-machine where there is no user at all.
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
idvalues as fresh GUIDs (uuidgen,[guid]::NewGuid(), or Terraform’srandom_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: trueon 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 norolesclaim — 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:
- Prefer app roles over raw group claims. A
rolesclaim does not overflow the same way and is the right abstraction for authorization. This is the single best mitigation. - If you need groups, scope them: set
groupMembershipClaims: "ApplicationGroup"so you only carry groups assigned to the app and never overflow on irrelevant memberships. - Handle the overage path explicitly — call
getMemberObjects(ortransitiveMemberOf) withsecurityEnabledOnly: 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 incoming token must be for your API. Its
audmust equal your API and itsscpmust contain a scope you exposed. OBO will not exchange a token whose audience is Graph, or a token with no user-delegated scope. If the frontend requested the wrong resource, OBO fails before it starts. - Your API’s registration must have the downstream delegated permission, admin-consented. The middle-tier app registration needs, say,
User.Readon Graph as a delegated permission, and that permission must be consented in the tenant. Missing it →AADSTS65001. - The user (or an admin) must have consented to the downstream permission. For user-consentable scopes the user’s first interactive sign-in can grant it; for admin-only scopes an administrator must grant it tenant-wide. Missing consent surfaces as
AADSTS65001; a Conditional-Access-protected downstream needing step-up surfaces asAADSTS50076/50079(interaction required), which you propagate to the client to re-authenticate.
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:
- Signature against the tenant’s published JWKS (defeats forgery).
- Issuer (
iss) matches your tenant’s expected issuer exactly, minding v1.0sts.windows.netvs v2.0login.microsoftonline.com/.../v2.0(defeats tokens from other authorities). - Audience (
aud) equals your API’s app ID URI or client ID — not Graph’s00000003-0000-0000-c000-000000000000(defeats accepting a token minted for a different resource). - Expiry / not-before (
exp,nbf) with small clock skew (defeats stale/replayed tokens). - Authorization: a required
scpvalue for delegated calls, or a requiredrolesvalue for app-only calls (enforces least privilege). - For ID tokens (client side only): the
noncematches 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")));
});
RequireRolereads therolesclaim.scpis space-delimited in one claim, so a substring check is wrong —Orders.ReadmatchesOrders.ReadBasic.RequireScope/[RequiredScope]fromMicrosoft.Identity.Websplits 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:
- Optional claims add Entra-known claims (e.g.
email,upn,acct,xms_pdl,ipaddr,groupswith a specific format) to the token. Configured on the app’soptionalClaims, no policy overhead, and safe — Entra owns the source data. - Claims mapping policies transform or source claims from directory schema/extension attributes, and are attached to a service principal. Using non-standard mappings historically required
acceptMappedClaims: trueon the app or a custom signing key, because mapping arbitrary directory data into a token is a spoofing surface — without the guard, one app could map another’s data.
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
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.Read ⊂ Orders.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 overage — groups 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
AADSTSin the logs) but the caller still got 403, the fault is in your authorization logic and the decodedroles/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
- Validate access tokens only; never accept an ID token at the API. The ID token’s audience is the client and it has no
scp/roles. Pinaudto your API and reject everything else — including Graph tokens. - Pin audience and issuer to this API and this tenant. Audience validation is the one check that stops a token minted for another resource from working against you. In multi-tenant apps, validate the issuer per-tenant and allow-list
tid. - Pull signing keys from OIDC discovery; never pin a key or cache JWKS forever. Point the handler at your
Authorityand let it refresh on rollover. Pinned keys are a guaranteed future outage. - Prefer app roles over raw group claims for authorization. Roles are stable, minted into the token, and do not overflow. Reserve group claims for genuinely fine-grained cases and always handle overage.
- When you must use group claims, scope them to
ApplicationGroupso you carry only groups assigned to the app, and handle overage (_claim_names) explicitly — never read a missinggroupsclaim as “no groups.” - Authorize on
scpfor user calls androlesfor app-only calls, and know which you have. Checkidtyp/absence ofscpto distinguish a daemon from a user. Never authorize a daemon on a user’soid. - Never substring-match
scp. Split on spaces and compare whole scope names; useRequireScope/[RequiredScope]. - Set
appRoleAssignmentRequired: truewhen roles are the gate. Otherwise unassigned users still authenticate and receive noroles, which confuses testing and weakens the gate. - Use a certificate or federated credential for OBO, not a client secret. A leaked middle-tier secret is the ability to impersonate every caller. Workload identity federation removes the secret entirely.
- Admin-consent downstream permissions before relying on OBO, and keep them least-privilege (
User.Read, notGroupMember.Read.All). Govern consent per OAuth consent governance. - Enforce authorization with policies, not ad hoc checks, so the rule is declared once and tested. Consider validating at the gateway (APIM
validate-jwt) as well. - Write the three cheap security tests: wrong-audience rejected, expired token rejected, missing-role/scope returns 403. They are the highest-value tests in the codebase.
- Lean on CAE for revocation, not on short token lifetimes. Access-token lifetime is not arbitrarily configurable; CAE is how you get near-real-time revocation of disabled/risky sessions.
Security notes
- Audience validation is the boundary. The most consequential security control here is pinning
aud. An API that validates signature and issuer but not audience will accept a token minted for Microsoft Graph — meaning any application that can obtain a Graph token (which is nearly all of them) can call your API. This is a real, exploited class of bug. - Least-privilege downstream consent. Grant your middle tier the narrowest Graph permission that works.
User.Readreads the caller’s own profile;GroupMember.Read.Allreads the entire directory’s group memberships. Solving a group-overage by escalating toGroupMember.Read.Alltrades a functional bug for a standing exfiltration risk. - Secrets out of the middle tier. Use a certificate or workload identity federation for the confidential client and for OBO. A leaked secret impersonates every user. Store any unavoidable secret in Key Vault via managed identity — see Azure Key Vault: Secrets, Keys and Certificates Done Right.
- Never trust unvalidated claims, and never trust claim presence as a gate. Optional claims are best-effort; an attacker cannot add claims to a signed token, but an absent claim (empty source attribute, overflowed
groups) must never silently downgrade or bypass authorization. - Reject
alg: noneand non-asymmetric algorithms. ConstrainValidAlgorithmstoRS256/PS256. A validator that honors the token’s ownalgheader can be tricked withnone. - Guard claims mapping policies. Non-standard mappings are a spoofing surface; require
acceptMappedClaimsor a custom signing key, and rotate that key. Prefer optional claims where they suffice. - Log identity, not secrets. Log
oid,azp,tid, and thescp/rolesyou authorized on for audit — never log the raw token, the refresh token, or the client secret. - Bind step-up to sensitive operations. For high-value actions, require a fresh, MFA-satisfied token (authentication context) and honor the CA claims challenge rather than accepting a stale session. This pairs with Privileged Identity Management and PAM Architecture: Just-in-Time Access at Scale for privileged paths.
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.
- Licensing. Core OAuth2/OIDC, app roles, delegated scopes, OBO, and basic group claims are available on Entra ID Free/included tiers. Advanced authorization inputs — Conditional Access, Identity Protection risk signals, and some governance features — require Entra ID P1/P2. CAE itself is available broadly, but the CA policies and risk detections that make it powerful are P1/P2. Group-based Conditional Access and dynamic groups need P1.
- Compute. Token validation is cheap CPU: a signature verification plus a few claim comparisons, with JWKS cached. It does not meaningfully move your App Service/AKS bill. The one thing that can is doing an OBO exchange and Graph call on every request without caching — MSAL caches the OBO result keyed by the incoming token, so a correctly configured middle tier hits the token endpoint rarely; a misconfigured one hammers it and adds latency (and can hit throttling).
- Operational cost. The real expense is incident time and blast radius: a wrong-audience acceptance is a security incident; a pinned-key outage is a full API outage; an overage bug denies your highest-value users during peak. These dwarf any infrastructure line item.
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
- 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?
- A daemon calls your API and gets 403 with an empty
rolesclaim. Name the two things that must be true for its app-only token to carry a role. - 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? - Your OBO call returns
AADSTS65001. What precondition failed, and how do you fix it? - True or false: to revoke a fired employee’s access within minutes, you configure a 5-minute access-token lifetime.
Answers
- 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. - 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 (appRoleAssignedTowithresourceId= your API’s SP) and admin-consented. Only then does the app-only token carryroles. - The token carries an overage indicator —
_claim_names.groups/_claim_sources(orhasgroups: true) — and nogroupsclaim, 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 missinggroupsclaim as “no groups.” - 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. - 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
- ID token — a signed JWT proving the user authenticated; its audience is the client, it carries identity claims (and
nonce), and it is never sent to or validated by an API. - Access token — a signed JWT (v2.0) whose audience is a resource; the credential an API validates and authorizes on via
scp/roles. - Refresh token — an opaque, long-lived token held by a client to obtain new access tokens silently; never parsed, never sent to a resource.
- JWT — a token of three base64url segments
header.payload.signature; only validation (signature + claims), not decoding, provides security. aud(audience) — the resource a token is for; must equal your API’s identifier, and Graph’s GUID is not your API.iss(issuer) — the token-issuing authority; v1.0sts.windows.net/<tid>/vs v2.0login.microsoftonline.com/<tid>/v2.0.scp(scopes) — space-delimited delegated permissions present when a user is signed in; split on spaces, never substring-match.roles— app roles granted, as a JSON array; present for user/group-assigned roles (delegated) or application-assigned roles (app-only).azp/appid— the client application that requested the token (azpin v2.0,appidin v1.0).oid— the immutable object ID of the principal; the stable cross-app identity key (subis per-app pairwise and not stable across apps).idtyp—appon an app-only token, signalling there is no user and you must authorize onroles.nonce— a replay-binding value in the ID token that the client must match to what it sent.- JWKS — the JSON Web Key Set of public signing keys, discovered via OIDC metadata (
jwks_uri) and rotated; pin nothing, refresh on unknownkid. - Delegated scope — a permission a client exercises for a user; effective permission is app-grant ∩ user-rights; appears as
scp. - App role — a named permission defined on an application (
appRoles) and assigned on the service principal to users/groups or to apps. appRoleAssignmentRequired— a flag on the resource SP; when true, unassigned users are blocked at sign-in (AADSTS50105) rather than merely receiving noroles.- Group-claims overage — when a user exceeds the token’s group cap (~150 JWT / ~200 SAML), Entra drops
groupsand emits_claim_names/_claim_sourcespointing at a Graph endpoint. - On-Behalf-Of (OBO) — the flow exchanging an incoming access token (
aud= your API) for a downstream token (aud= Graph/other) as the same user. - Consent — the recorded grant that a client/API may exercise a permission; user or admin (delegated), always admin for application permissions; a precondition for OBO.
- Optional claims — Entra-known claims (
email,upn,acct) added viaoptionalClaims; best-effort, so never gate on their presence. - Claims mapping policy — a policy on a service principal that sources/transforms claims from directory data; requires
acceptMappedClaims/custom key as a spoofing guard. - CAE (Continuous Access Evaluation) — near-real-time session revocation on critical events (account disabled, password reset, high risk) via a claims challenge, replacing short token lifetimes.
AADSTScode — an Entra error code emitted during token acquisition and recorded in sign-in logs (e.g.65001consent,50105unassigned role,50076interaction required).
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:
- Next: Building a Secure OIDC Confidential Client in Entra ID: App Registrations, Secrets, and Workload Identity Federation — how the app authenticates itself, the layer beneath every token in this article.
- Related: Governing OAuth Consent and Application Permissions in Entra ID: Stopping Illicit Consent and Hardening App Trust — consent as a security boundary and the precondition for OBO.
- Related: Managed Identities Deep Dive: User-Assigned Identities, Federated Credentials, and RBAC Patterns for Azure Workloads — remove the middle-tier secret with workload identity federation.
- Related: APIM Policies From Scratch: Rate-Limit-By-Key, JWT Validation and Request/Response Transformation — enforce the same token validation at the gateway.
- Related: Designing Conditional Access at Scale: A Persona-Based Policy Framework with Authentication Context and Filters — the policies behind step-up and CAE-driven revocation.
- Related: Configuring SAML 2.0 SSO for a Custom Enterprise App in Entra ID with Advanced Claims Mapping — the SAML-side analogue of claims and mapping for non-OIDC apps.