Most Entra ID incidents I get paged for trace back to one object and one mistake: a client secret that expired at 02:00 UTC, leaked into a build log, or got copy-pasted into a fourth pipeline that nobody documented. The app didn’t change, the code didn’t change — a 24-month timer ran out, or a credential ended up somewhere a credential should never be, and a production integration went dark. This is the soup-to-nuts build I use for a confidential OIDC client in Microsoft Entra ID: the app registration anatomy, the redirect URIs, the authorization-code-with-PKCE flow for users, the client-credentials and on-behalf-of flows for services, least-privilege Graph permissions and admin consent, the token and claims configuration — and then the part that actually ends the 02:00 pages, replacing every secret and certificate with workload identity federation.
A confidential client is any OAuth2/OIDC client that can keep a credential confidential — a server-side web app, an API, a daemon, a CI pipeline — as opposed to a public client (a SPA or mobile app) that can hold no secret because the binary or the browser ships to the user. The defining act of a confidential client is that it authenticates itself to the token endpoint. For thirty years that meant a password. The thesis of this article is that for machine-to-machine and CI scenarios you should hold no password at all: an external identity provider you already trust (GitHub Actions, an AKS cluster, AWS, GCP, any OIDC issuer) mints a short-lived token, and Entra exchanges it for an Entra access token through a federated identity credential (FIC). No secret on the app. Nothing to rotate. Nothing to leak.
By the end you will be able to register a confidential application and materialize its service principal deliberately, choose the right OIDC/OAuth2 flow for each caller, request the narrowest Graph permissions and consent them correctly, configure tokens and optional claims, and — the payoff — run production sign-in and secretless machine-to-machine auth for the same app, then delete every standing credential so there is no fallback to abuse. Everything is real az, Microsoft Graph, and Terraform; every limit, error string, and well-known GUID is one you can paste.
What problem this solves
App registrations look deceptively simple in the portal — a name, a redirect URI, a “New client secret” button — and that simplicity is exactly the trap. The portal lets a junior engineer create a multi-tenant app with implicit-grant tokens, an over-broad Directory.ReadWrite.All application permission, and a two-year secret in about ninety seconds, and every one of those is a production-grade mistake. The object model underneath (application vs service principal, delegated vs application permissions, app roles vs scopes, the consent grant living on the SP and not the app) is genuinely subtle, and getting it wrong produces failures that surface weeks later as “the integration randomly broke” or, worse, as a finding in a security review.
What breaks without this knowledge falls into three buckets. Secret lifecycle failures: secrets expire on a timer nobody tracks, get committed to git, end up in CI variables across a dozen pipelines, and rotation becomes an error-prone choreography across every consumer at once. Authorization failures: the wrong permission class (delegated where you needed application, or =Scope where you meant =Role) silently grants too little or too much; admin consent gets “granted” on the app request but never lands on the service principal; a token minted for another app gets replayed at yours because nobody validates the audience. Federation failures: a federated credential’s subject doesn’t byte-for-byte match the incoming token’s sub, and you get AADSTS700213 at the worst possible moment, on a Friday deploy.
Who hits this: every team standing up server-side auth (web apps and APIs doing user sign-in), every platform team wiring CI/CD to deploy into Azure, every microservice calling Microsoft Graph or a downstream API as itself, and every ISV building a multi-tenant SaaS that other organizations consent into. The fix is almost never “rotate the secret faster” — it is “stop holding a secret at all where a workload identity can prove who it is, and hold a certificate (not a password) where it can’t.”
To frame the whole field before the deep dive, here is every credential and flow this article covers, the caller it serves, and the one-line reason it exists:
| Mechanism | Who uses it | What it proves / does | Holds a long-lived secret? | First place it bites |
|---|---|---|---|---|
| Auth code + PKCE | Confidential web app, SPA | A user signed in; app gets tokens on their behalf | App: yes (web) / no (SPA) | Redirect-URI / PKCE mismatch |
| Client credentials | Daemon, CI, microservice (no user) | The app itself, app-only token, tenant-wide | Yes, unless federated | Wrong permission class; secret expiry |
| On-behalf-of (OBO) | Middle-tier API | Exchanges a user token for a downstream token, preserving the user | Yes, unless federated | Missing knownClientApplications / consent |
| Client secret | Any confidential client | A shared password to the token endpoint | Yes (24-mo cap) | Expiry, leakage, sprawl |
Certificate (private_key_jwt) |
Any confidential client | A signed assertion; private key never leaves you | Key, not a password | Rotation, CA trust, distribution |
| Federated credential (FIC) | Workloads (CI, K8s, clouds) | External IdP token → Entra token, secretless | No | subject/audience mismatch |
Learning objectives
By the end of this article you can:
- Distinguish the application object from the service principal precisely, and explain which artifacts (redirect URIs, exposed scopes, FICs) live on the app versus which (role assignments, admin consent, sign-in policy) live on the SP — and create both deliberately with
az, Microsoft Graph, and Terraform. - Choose the correct OAuth2/OIDC flow for any caller — authorization code with PKCE for interactive users, client credentials for app-only daemons and CI, on-behalf-of for middle-tier APIs — and wire each end to end with the exact request parameters.
- Request the narrowest Microsoft Graph permissions, tell delegated from application permissions (and
=Scopefrom=Role), grant admin consent correctly, and verify the grant lands on the service principal rather than only on the app request. - Configure token settings and optional claims, expose your own app roles and scopes, set the access-token version and groups/
idtypclaims, and reason about token lifetime and the security implications ofaccessTokenAcceptedVersion. - Replace client secrets first with certificates (
private_key_jwt), then — for workloads — with workload identity federation: attach federated identity credentials for GitHub Actions, Kubernetes/AKS, AWS, GCP, and arbitrary OIDC issuers, including the new flexible FIC claims-matching expressions. - Validate Entra access tokens correctly as a resource server (signature via JWKS,
iss,aud,exp/nbf, version), and apply Conditional Access for workload identities to pin federated sign-in to known network locations. - Harden the whole registration: least privilege, no standing credentials, FIC-create alerting, audit-log streaming, and a recurring audit that fails any privileged app still carrying a password or key.
Prerequisites & where this fits
You should be comfortable with the OAuth2/OIDC vocabulary at a high level — authorization endpoint, token endpoint, access token, ID token, refresh token, scopes — and with running az in Cloud Shell, reading JSON output, and decoding a JWT (paste into jwt.ms or jwt-cli). You need a tenant where you can create app registrations and, for the consent and federation steps, a role that can grant admin consent: Cloud Application Administrator or Application Administrator (and Privileged Role Administrator if you assign directory roles to the SP). You should know what a managed identity is, because federated credentials and user-assigned managed identities are two routes to the same secretless destination, and we contrast them.
This sits at the center of the Identity → application integration track. It is the deep build behind App Registrations vs Enterprise Applications in Entra ID (the object-model primer) and assumes the flow mechanics from OIDC and OAuth2 Flows in Entra ID: Authorization Code with PKCE. The claims and OBO material extends Entra ID Token Claims, App Roles, and the On-Behalf-Of Flow. For the secretless half, it pairs with Workload Identity Federation for Secretless CI/CD, GitHub Actions to Azure with OIDC Federated Credentials, and the managed-identity alternative in Entra Managed Identities Deep Dive: User-Assigned, FIC, RBAC. Consent governance is its own discipline, covered in Entra OAuth Consent Governance and App Permission Hardening.
A quick map of who owns what across this build, so you route work and incidents to the right person:
| Layer / artifact | Where it lives | Who usually owns it | What goes wrong here |
|---|---|---|---|
| Application object (registration) | Home tenant /applications |
App / platform team | Wrong signInAudience, broad permissions, http redirect |
| Service principal (enterprise app) | Each tenant /servicePrincipals |
IT / identity admin | Disabled SP, missing role assignment, missing consent |
| Admin consent grant | On the SP (oauth2PermissionGrants, appRoleAssignments) |
Global / app admin | Consented to wrong scope class; not granted at all |
| Federated identity credentials | On the app /applications/{id}/federatedIdentityCredentials |
Platform + security | subject/issuer/audience mismatch; 20-FIC ceiling |
| Conditional Access (workload) | Tenant policies, SP-targeted | Security / identity | Federated token honored from any network |
| Token validation | Your resource API | App / dev team | Missing aud/iss/signature checks; wrong token version |
Core concepts
Six mental models make every later decision obvious. Internalize these and the rest is detail.
The application and the service principal are two objects, not one. The application object (/applications) is the global definition of your app: its display name, redirect URIs, the scopes it exposes, the permissions it requests, the app roles it defines, the federated credentials it trusts. It lives in your home tenant and is referenced everywhere by its appId (the client ID). The service principal (/servicePrincipals) is the local instance of that application inside a tenant — the concrete identity that signs in, holds role assignments, receives admin-consent grants, and is targeted by Conditional Access. A single-tenant app has one application and one SP in the same tenant; a multi-tenant app has one application in the home tenant and a service principal in every tenant that consents. When you assign RBAC or write a CA policy, you target the SP’s object ID, not the application’s.
The status of a confidential client is that it authenticates itself. Every confidential client presents a client credential at the token endpoint: a secret, a certificate assertion (client_assertion signed by your private key), or a federated assertion (client_assertion that is an external IdP’s token). The whole arc of this article is moving you down that list — from a password you must protect, to a key you must protect, to nothing you must protect.
Permissions come in two incompatible classes, and consent lands on the SP. Delegated permissions (Scope) let the app act as a signed-in user — effective access is the intersection of what the app is granted and what the user can do. Application permissions (Role) let the app act as itself, with no user, exactly what is granted, tenant-wide — and they always require admin consent. The grant of either is written onto the service principal (delegated grants as oauth2PermissionGrants, app-role grants as appRoleAssignments), which is why “I requested it on the app” is not the same as “it is granted.”
Scopes are what callers ask of you; app roles are what you assign. When you expose your own API, you publish scopes (delegated, user-consentable, appear in the scp claim) and app roles (assigned to users/groups/SPs, appear in the roles claim). Scopes model delegated user permission; app roles model application identity and app-only authorization. A daemon calling your API uses an app role; a user-delegated client uses a scope.
A token is only as good as your validation of it. An access token issued for your API must be validated: signature against the tenant’s JWKS, iss against the discovery document, aud against your app ID URI or client ID, exp/nbf for time, and the token version (v1.0 vs v2.0) because the claim shapes differ. An ID token authenticates a user to a client and is not an API authorization credential. A Graph access token is opaque to you — you never validate it; you send it to Graph.
Federation replaces “what I hold” with “who I am, proven by someone you trust.” A federated identity credential declares a trust on the application: “I will accept a token from this issuer, carrying this subject, for this audience (api://AzureADTokenExchange).” At runtime the workload obtains a short-lived OIDC token from its own platform and presents it as the client_assertion in a client-credentials request; Entra validates it against the FIC and issues an Entra token. No secret is stored anywhere.
The vocabulary in one table
Pin down every moving part before the deep sections; the glossary repeats these for lookup, but here they sit side by side:
| Term | One-line definition | Where it lives | Why it matters here |
|---|---|---|---|
| Application object | Global app definition | Home tenant /applications |
Holds redirect URIs, scopes, FICs, requested perms |
| Service principal | Local instance of the app in a tenant | Each tenant /servicePrincipals |
Holds role assignments, consent, CA target |
appId (client ID) |
Public identifier of the application | On the app & SP | Used in every auth request |
Object ID (id) |
Directory key of an object | Per object | RBAC/CA attach to the SP’s object ID |
| Redirect URI | Where Entra returns the auth code | App (per platform) | Must match exactly; HTTPS-only (except localhost) |
Delegated permission (Scope) |
App acts as the signed-in user | App request → SP grant | Effective = app ∩ user rights |
Application permission (Role) |
App acts as itself, tenant-wide | App request → SP appRoleAssignment |
Admin consent mandatory |
| App role | Role you define and assign | App appRoles → SP assignment |
Appears in roles claim |
| Scope you expose | Delegated permission on your API | App oauth2PermissionScopes |
Appears in scp claim |
| Client secret | Shared password credential | App passwordCredentials |
Expires, leaks, sprawls |
| Certificate credential | Public key; you hold the private key | App keyCredentials |
private_key_jwt; key in HSM |
| Federated credential (FIC) | Trust to an external OIDC issuer | App federatedIdentityCredentials |
Secretless; ≤20 per app |
| Admin consent | Tenant-wide grant of permissions | SP grants | Required for app perms & high-risk scopes |
| Conditional Access (workload) | Policy targeting a service principal | Tenant policy | Pins federated sign-in to network/location |
App registration anatomy: application vs service principal
The single most common confusion in Entra ID is treating the application and the service principal as one thing. They are not, and the consequences of conflating them are concrete: you assign a role to the wrong object and it silently does nothing; you look for a consent grant on the app and don’t find it; you write a CA policy that never matches because it targets the application’s ID.
When you register an app in your own tenant via the portal, Entra creates both objects for you. Through the API you create them separately and on purpose. The application object comes first; the service principal is materialized from it.
# 1) Create the application object. Returns appId (client ID) and id (object ID).
az ad app create \
--display-name "kv-oidc-confidential-client" \
--sign-in-audience AzureADMyOrg
APP_ID=$(az ad app list --display-name "kv-oidc-confidential-client" --query "[0].appId" -o tsv)
APP_OBJ_ID=$(az ad app list --display-name "kv-oidc-confidential-client" --query "[0].id" -o tsv)
# 2) Materialize the service principal (the enterprise app) in THIS tenant.
az ad sp create --id "$APP_ID"
SP_OBJ_ID=$(az ad sp show --id "$APP_ID" --query id -o tsv)
echo "appId=$APP_ID app.objectId=$APP_OBJ_ID sp.objectId=$SP_OBJ_ID"
The same in Terraform with the azuread provider, which makes the two-object reality explicit — azuread_application and azuread_service_principal are separate resources:
resource "azuread_application" "oidc_client" {
display_name = "kv-oidc-confidential-client"
sign_in_audience = "AzureADMyOrg"
web {
redirect_uris = ["https://app.kloudvin.com/auth/callback"]
implicit_grant {
access_token_issuance_enabled = false # implicit is dead — keep it off
id_token_issuance_enabled = false
}
}
}
resource "azuread_service_principal" "oidc_client" {
client_id = azuread_application.oidc_client.client_id
app_role_assignment_required = false
owners = [data.azuread_client_config.current.object_id]
}
Choosing the sign-in audience deliberately
signInAudience decides who can get a token from this app, and it changes token validation downstream. Pick it on purpose; default to single-tenant unless you have a real cross-org requirement, because every widening of the audience widens your validation and consent surface.
signInAudience |
Who can sign in | Issuer at runtime | Token validation note |
|---|---|---|---|
| AzureADMyOrg | Only your tenant | https://login.microsoftonline.com/{yourTenantId}/v2.0 |
Single, fixed issuer — simplest to validate |
| AzureADMultipleOrgs | Any Entra tenant that consents | Per-tenant issuer | Validate tenant against an allowlist, not just the template |
| AzureADandPersonalMicrosoftAccount | Any Entra tenant + MSA | …/common/v2.0 / per-tenant |
Must handle MSA tid and consumer issuer |
| PersonalMicrosoftAccount | MSA only | …/consumers/v2.0 |
Consumer-only; rare for enterprise APIs |
Redirect URIs and platform types
Redirect URIs are typed by platform, and the platform changes the rules. A confidential server-side web app uses the web platform (can hold a secret, returns the code to a server endpoint). A SPA uses the spa platform, which forces PKCE and forbids secrets (the browser can’t hold one). Native/mobile uses publicClient. Entra rejects plain http redirect URIs except for localhost (for local dev) — production must be HTTPS.
# Web (confidential) redirect URI — HTTPS enforced.
az ad app update --id "$APP_ID" \
--web-redirect-uris "https://app.kloudvin.com/auth/callback"
# If you ALSO ship a SPA front-end against the same app, add an spa redirect via Graph:
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJ_ID" \
--headers 'Content-Type=application/json' \
--body '{ "spa": { "redirectUris": ["https://app.kloudvin.com/spa"] } }'
The redirect-URI rules and limits you actually trip over:
| Rule | Detail | Why it matters |
|---|---|---|
| Exact match | The redirect_uri in the request must match a registered URI character-for-character |
A trailing slash or http/https difference fails with AADSTS50011 |
| HTTPS required | Only https (plus http://localhost for dev) |
Plain http to a real host is rejected |
| Per-platform buckets | web, spa, publicClient are separate lists |
A SPA URI registered under web won’t satisfy a SPA request |
| Count cap | Up to ~256 redirect URIs across platforms (practical limit) | Don’t enumerate per-environment; use few, parameterize the rest |
| Wildcards | Not supported for the host/path | You list each one; no *.kloudvin.com |
| Query/fragment | response_mode decides whether code returns in query or fragment |
query for web, fragment/form_post patterns differ |
The manifest is just the Graph object
The “manifest” you used to edit by hand in the portal is the JSON representation of the application object, now aligned to the Microsoft Graph application schema. Prefer az ad app update or a Graph PATCH over manual manifest edits — the manifest is easy to corrupt, offers no validation, and a single misplaced comma can brick the registration. Treat the registration as code (Terraform or a reviewed Graph script), not as a thing you click.
The OIDC authorization code flow with PKCE
For a confidential client doing interactive user sign-in, the authorization code flow with PKCE is the only flow you should ship. The implicit flow (tokens returned directly from the authorize endpoint) is dead — do not enable access-token or ID-token issuance on the implicit grant; both Microsoft and the OAuth working group have moved on, and implicit tokens land in browser history and referrer headers.
The flow, end to end:
- The app generates a
code_verifier(a high-entropy random string, 43–128 chars) and derivescode_challenge = BASE64URL(SHA256(code_verifier)). - The browser is redirected to
/authorizewithresponse_type=code, thecode_challenge, andcode_challenge_method=S256. - The user authenticates (and satisfies any Conditional Access); Entra returns an authorization
codeto the registered redirect URI. - The backend exchanges the
codeat/token, presenting thecode_verifierand its client credential.
The authorization request (note the v2.0 endpoint, which issues v2 tokens and uses scope-based permissions):
GET https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
?client_id={APP_ID}
&response_type=code
&redirect_uri=https://app.kloudvin.com/auth/callback
&response_mode=query
&scope=openid%20profile%20offline_access%20User.Read
&code_challenge={code_challenge}
&code_challenge_method=S256
&state={opaque_csrf_state}
&nonce={opaque_nonce}
The token exchange — the one place a confidential web client authenticates itself, and the part we eliminate later for workloads (here still shown with a secret for the user-facing web case):
curl -s -X POST \
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d "client_id=${APP_ID}" \
-d "grant_type=authorization_code" \
-d "code=${AUTH_CODE}" \
-d "redirect_uri=https://app.kloudvin.com/auth/callback" \
-d "code_verifier=${CODE_VERIFIER}" \
-d "scope=openid profile offline_access User.Read" \
-d "client_secret=${CLIENT_SECRET}"
PKCE protects the authorization code in transit (an intercepted code is useless without the verifier); the client credential proves the caller is the registered confidential client. You want both. Request offline_access only if you genuinely need a refresh token to act when the user is away — every refresh token is a long-lived credential to protect.
The authorize-request parameters that matter and their failure modes:
| Parameter | Required? | Values | Gotcha / error if wrong |
|---|---|---|---|
response_type |
Yes | code |
Anything else (e.g. token) is implicit — don’t |
redirect_uri |
Yes | A registered web URI |
Mismatch → AADSTS50011 |
response_mode |
No | query, fragment, form_post |
query for web; form_post for some SSR patterns |
scope |
Yes | openid + others, space-delimited |
Missing openid → no ID token |
code_challenge |
Yes (PKCE) | BASE64URL(SHA256(verifier)) | Wrong method → AADSTS501481-class errors |
code_challenge_method |
Yes (PKCE) | S256 (never plain) |
plain is weak; use S256 |
state |
Strongly | Opaque random | Omit → CSRF exposure; validate on return |
nonce |
For OIDC | Opaque random | Bind into ID token; validate to stop replay |
prompt |
No | login, consent, select_account, none |
none for silent; fails if interaction needed |
login_hint / domain_hint |
No | UPN / domain | Pre-fills account / routes to a federated IdP |
The four flows compared, so you never reach for the wrong one:
| Flow | Caller | User present? | Client auth | Tokens returned | Use when |
|---|---|---|---|---|---|
| Auth code + PKCE | Web app, SPA | Yes | Web: yes / SPA: no | ID + access (+ refresh) | Interactive user sign-in |
| Client credentials | Daemon, CI, service | No | Yes (secret/cert/FIC) | App-only access | App acting as itself |
| On-behalf-of | Middle-tier API | Yes (upstream) | Yes | Downstream access | API calls another API as the user |
| Device code | Input-constrained device | Yes | Public | ID + access (+ refresh) | TVs, CLIs with no browser |
| Resource owner password (ROPC) | Legacy only | Yes (password) | Yes | access (+ refresh) | Avoid — breaks MFA/CA, last resort |
The client credentials flow (app-only)
When there is no user — a nightly job, a microservice, a CI deploy — the app authenticates as itself with the client credentials grant and receives an app-only access token. This is the flow that consumes application permissions (the Role class), and it is also the flow that workload identity federation plugs into, because federation simply replaces the client_secret with a federated client_assertion.
The classic shape, with a secret (we replace this credential later):
curl -s -X POST \
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d "client_id=${APP_ID}" \
-d "grant_type=client_credentials" \
-d "scope=https://graph.microsoft.com/.default" \
-d "client_secret=${CLIENT_SECRET}"
Two things are non-obvious. First, in the v2 client-credentials flow you request scope=<resource>/.default — the .default scope means “all the application permissions already consented for this app on this resource,” not a dynamic scope; you cannot request individual app permissions at runtime, only what consent already granted. Second, the resulting token’s roles claim carries the app permissions, and idtyp=app marks it app-only (no user). A resource API should check idtyp/roles for app-only callers and scp for delegated ones.
The client-credentials request parameters and their meaning:
| Parameter | Value | Note |
|---|---|---|
grant_type |
client_credentials |
App-only, no user |
scope |
https://graph.microsoft.com/.default (or api://<your-api>/.default) |
.default = consented app perms; dynamic scopes invalid here |
client_id |
The appId |
The confidential client |
client_secret |
The secret | Replace with cert or FIC |
client_assertion_type |
urn:ietf:params:oauth:client-assertion-type:jwt-bearer |
When using cert or FIC instead of secret |
client_assertion |
A signed JWT (cert) or external token (FIC) | The secretless credential |
The on-behalf-of flow (middle-tier APIs)
A middle-tier API often needs to call a further downstream API as the original user, not as itself — a gateway API that received a user’s token and must call Microsoft Graph or another internal API with that user’s identity and permissions intact. The on-behalf-of (OBO) flow exchanges the incoming user access token for a new access token scoped to the downstream resource, preserving the user. Without it you’d either lose the user context (falling back to app-only, which over- or under-authorizes) or force the client to acquire every downstream token itself (leaking your topology to the front end).
The exchange is a token request where grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, the assertion is the user’s token your API received, and the API authenticates with its own client credential (secret, certificate, or — yes — a FIC):
curl -s -X POST \
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d "client_id=${MIDDLE_TIER_APP_ID}" \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=${INCOMING_USER_ACCESS_TOKEN}" \
-d "scope=https://graph.microsoft.com/User.Read" \
-d "requested_token_use=on_behalf_of" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=${MIDDLE_TIER_CLIENT_ASSERTION}"
Two configuration facts make OBO work or fail. The middle-tier app must hold the delegated permission to the downstream resource (it acts as the user, so delegated, not application). And for a smooth single-consent experience, set knownClientApplications on the middle-tier app to the client app’s appId, so consenting to the client also consents the API’s downstream access — otherwise users hit a second consent prompt or a consent error.
The OBO request parameters and the configuration they depend on:
| Parameter / setting | Value | Why it matters |
|---|---|---|
grant_type |
urn:ietf:params:oauth:grant-type:jwt-bearer |
Selects the OBO/JWT-bearer exchange |
assertion |
The incoming user access token | The identity being preserved downstream |
requested_token_use |
on_behalf_of |
Tells Entra this is an OBO exchange |
scope |
Downstream delegated scope (e.g. …/User.Read) |
What the new token is for |
client_assertion/client_secret |
Middle-tier’s own credential | The API authenticates itself (FIC-able) |
| Downstream delegated permission | Granted + consented on the middle-tier app | Without it: AADSTS65001/500131 |
knownClientApplications |
Client app’s appId on the API |
Single-consent; avoids second prompt |
Delegated vs application permissions and least privilege
Two permission models, and conflating them is a recurring security finding in the reviews I run. The table is the whole idea; the prose is the warning.
Delegated (Scope) |
Application (Role) |
|
|---|---|---|
| Acts as | The signed-in user (app + user) | The app itself, no user |
| Token via | Auth code / OBO flow | Client credentials flow |
| Effective access | Intersection of app’s grant and the user’s own rights | Exactly what’s granted, tenant-wide |
| Consent | User (low-risk) or admin (high-risk) | Admin only, always |
| Appears in | scp claim |
roles claim |
| Example | User.Read (read my profile) |
User.Read.All (read every user) |
The trap with application permissions is that they ignore the user entirely. User.Read.All as an application permission reads every user in the tenant; Mail.Read as an application permission reads every mailbox. Grant the narrowest scope that works, prefer delegated where a user is present, and never reach for Directory.ReadWrite.All when a targeted permission exists.
# Microsoft Graph's resource appId is a well-known constant.
GRAPH_APP_ID="00000003-0000-0000-c000-000000000000"
# Add the DELEGATED User.Read scope (this GUID is the stable id for User.Read).
az ad app permission add --id "$APP_ID" \
--api "$GRAPH_APP_ID" \
--api-permissions e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope
# Add an APPLICATION permission (=Role). Example: User.Read.All (application) GUID.
az ad app permission add --id "$APP_ID" \
--api "$GRAPH_APP_ID" \
--api-permissions df021288-bdef-4463-88db-98f22de89214=Role
# Grant admin consent — writes the grant onto the SERVICE PRINCIPAL.
az ad app permission admin-consent --id "$APP_ID"
The =Scope vs =Role suffix is load-bearing: =Scope is delegated, =Role is application. Get it wrong and you grant the wrong class of access — often silently, because the app “works” in your delegated test and only fails (or over-permits) in the app-only path. Always confirm the resulting grant on the SP, not just the request on the app.
A reference of the Graph permissions you reach for most, by class, so you can see the delegated/application asymmetry at a glance:
| Permission | Class | Reads/does | Narrower alternative |
|---|---|---|---|
User.Read |
Delegated | The signed-in user’s profile | (already minimal) |
User.ReadBasic.All |
Delegated | Basic profiles of all users | User.Read if only self |
User.Read.All |
Application | Every user’s full profile | User.ReadBasic.All (delegated) |
Mail.Send |
Delegated | Send as the signed-in user | Mail.Send (application) only for daemons |
Mail.Send |
Application | Send as any mailbox | + RBAC application access policy to scope mailboxes |
Group.Read.All |
Application | All groups | GroupMember.Read.All if only membership |
Directory.Read.All |
Application | Whole directory (broad) | Specific *.Read.All you actually need |
Directory.ReadWrite.All |
Application | Whole directory write (very broad) | Almost always overkill — avoid |
Application.ReadWrite.OwnedBy |
Application | Only apps this SP owns | vs Application.ReadWrite.All |
A note on resource-specific consent and scoping app-only access: for some workloads (notably Exchange/Teams) an application permission like Mail.Send can be narrowed to specific mailboxes or teams via an application access policy or RSC, so “application permission” need not mean “everything.” Reach for these to keep app-only access least-privilege.
Exposing your own API: scopes and app roles
When your app is the resource (an API others call), you publish what callers can request. Two artifacts, two audiences.
Scopes (oauth2PermissionScopes) are delegated permissions on your API — they appear in the caller’s scp claim and can be user- or admin-consented. App roles (appRoles) are assigned to users, groups, or other service principals and appear in the roles claim — they model both user authorization (role-based UI) and app-only authorization (which daemon may call which operation).
First set an Application ID URI so your scopes have a namespace, then define a scope:
# Set the Application ID URI (the audience callers request).
az ad app update --id "$APP_ID" --identifier-uris "api://$APP_ID"
# Define a delegated scope "Tasks.Read" via the manifest/Graph.
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJ_ID" \
--headers 'Content-Type=application/json' \
--body '{
"api": {
"oauth2PermissionScopes": [{
"id": "11111111-2222-3333-4444-555555555555",
"adminConsentDisplayName": "Read tasks",
"adminConsentDescription": "Allows the app to read the user'\''s tasks",
"userConsentDisplayName": "Read your tasks",
"userConsentDescription": "Allows the app to read your tasks",
"value": "Tasks.Read",
"type": "User",
"isEnabled": true
}]
}
}'
Define an app role for an app-only caller (a daemon that processes tasks):
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJ_ID" \
--headers 'Content-Type=application/json' \
--body '{
"appRoles": [{
"id": "66666666-7777-8888-9999-aaaaaaaaaaaa",
"allowedMemberTypes": ["Application"],
"displayName": "Tasks.Process",
"description": "Allows a service to process all tasks",
"value": "Tasks.Process",
"isEnabled": true
}]
}'
Scopes versus app roles, decided:
| Aspect | Scope (oauth2PermissionScopes) |
App role (appRoles) |
|---|---|---|
| Models | Delegated user permission | User/group/app assignment & app-only auth |
| Claim | scp (space-delimited) |
roles (array) |
| Consent | User or admin | Assignment (and consent for app members) |
allowedMemberTypes |
n/a | User, Application, or both |
| Best for | “App may read tasks as the user” | “This daemon may process tasks” / “this user is an Admin” |
| Enforcement in your API | Check scp contains the scope |
Check roles contains the role |
A hardening lever worth setting: on the service principal, set appRoleAssignmentRequired = true so only explicitly assigned identities can get a token for your app — no app role assignment, no token. This turns your API into allowlist-by-default.
Admin consent and the consent grant
“I added the permission” and “the permission is granted” are different states, and the gap is where hours disappear. Adding a permission writes a request onto the application (requiredResourceAccess). Consent writes a grant onto the service principal. Until the grant exists on the SP, tokens won’t carry the permission.
| State | Where it’s recorded | What it means | How to check |
|---|---|---|---|
| Permission requested | App requiredResourceAccess |
“This app wants X” | az ad app permission list --id $APP_ID |
| Delegated grant | SP oauth2PermissionGrants |
“X is consented (delegated)” | az ad app permission list-grants |
| App-role grant | SP appRoleAssignments |
“X is consented (application)” | Graph /servicePrincipals/{id}/appRoleAssignments |
| Admin-consent required | App perm flagged high-risk / application class | “A user can’t self-consent” | Portal shows “Admin consent required: Yes” |
Grant and verify, end to end:
# Grant admin consent for all requested permissions.
az ad app permission admin-consent --id "$APP_ID"
# Verify DELEGATED grants landed on the SP (not just requested on the app).
az ad app permission list-grants --id "$APP_ID" -o table
# Verify APPLICATION (app-role) grants on the SP via Graph.
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJ_ID/appRoleAssignments" \
--query "value[].{resource:resourceDisplayName, appRoleId:appRoleId}" -o table
Two governance facts that bite teams: user consent can be restricted tenant-wide (often to “verified publishers, low-risk permissions only” or off entirely), which is why a user clicks “Accept” and still gets AADSTS65001-class consent errors — an admin must consent. And the admin consent workflow lets users request consent that an admin approves, which is the right pattern in a locked-down tenant. Governance of all this is its own topic; see Entra OAuth Consent Governance and App Permission Hardening.
Token configuration, versions, and optional claims
Tokens are configurable, and the defaults are not always what you want. Three levers matter most: the access-token version, optional claims, and group/role claims.
Access-token version. The accessTokenAcceptedVersion property (in the app’s api config) controls whether your API receives v1.0 or v2.0 access tokens. v2 tokens have aud set to your appId/api:// URI and use the v2 issuer; v1 tokens (accessTokenAcceptedVersion: null or 1) use the v1 issuer and may set aud to the resource URI. Mismatches here are a top cause of “the token validates in one library but not another.” Set it explicitly and validate against the matching issuer/audience.
| Token aspect | v1.0 | v2.0 |
|---|---|---|
Issuer (iss) |
https://sts.windows.net/{tid}/ |
https://login.microsoftonline.com/{tid}/v2.0 |
aud for your API |
Resource URI (e.g. api://…) |
appId or api://… (per config) |
| Permissions model | Resource-scoped | Scope/.default-based |
| Set via | accessTokenAcceptedVersion: 1/null |
accessTokenAcceptedVersion: 2 |
| Endpoint | /oauth2/authorize (v1) |
/oauth2/v2.0/authorize |
Optional claims let you add claims your app needs (e.g. idtyp, acct, auth_time, xms_pl) to ID, access, or SAML tokens without bloating every token. Group claims can emit the user’s group object IDs in the groups claim — but with a hard cap: if a user is in more than the limit (~200 for an access token, ~150 for SAML), Entra emits a _claim_names/_claim_sources overage pointer instead of the groups, and you must call Graph to enumerate. The robust pattern for authorization is to emit app roles (assigned, bounded) rather than raw groups.
# Configure optional claims + group claim (object IDs) via Graph.
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications/$APP_OBJ_ID" \
--headers 'Content-Type=application/json' \
--body '{
"api": { "acceptMappedClaims": null, "requestedAccessTokenVersion": 2 },
"optionalClaims": {
"idToken": [ { "name": "idtyp", "essential": false } ],
"accessToken": [ { "name": "idtyp", "essential": false } ]
},
"groupMembershipClaims": "SecurityGroup"
}'
The claims you most often add or rely on, and why:
| Claim | Token | Meaning | When to add |
|---|---|---|---|
scp |
Access | Delegated scopes granted | Always check on delegated calls |
roles |
Access/ID | App roles assigned | Authorization; app-only and user roles |
idtyp |
Access | app (app-only) vs user |
Distinguish app-only from delegated |
groups |
Access/ID | Group object IDs | Group-based authz (watch overage cap) |
wids |
Access/ID | Directory-role template IDs | Detect tenant admins |
oid / sub |
All | Stable user/app object id / subject | Keying the principal (use oid, not email) |
acr / amr |
ID | Auth context / methods | Step-up & MFA awareness |
xms_cc |
Access | Client capabilities (CAE) | Continuous access evaluation |
A token-lifetime note: Entra access tokens default to roughly 60–90 minutes (variable, and now subject to Continuous Access Evaluation which can revoke near-real-time). You can no longer arbitrarily shorten access-token lifetime via the old configurable token lifetime policy for most token types; lean on CAE and short-lived federation instead. Do not stretch lifetimes — short tokens plus federation mean a stolen token is useless fast.
Why client secrets and certificates are the problem
A confidential client must prove who it is. The classic options each carry a lifecycle you must operate, and that operation is where the failures live.
Client secrets are passwords. Entra now caps a newly created secret at 24 months; they end up in CI variables, .env files, tickets, and chat; and rotation is a manual choreography you must coordinate across every consumer at once or something breaks. A secret in a build log is exploitable for months.
Certificates (private_key_jwt) are strictly better: the app signs a JWT assertion with a private key that can live in a KMS/HSM and never leave it, so there is no shared password to leak. But you still own a key with a full lifecycle — issuance, distribution, rotation, revocation, a CA to trust — and a cert that expires unmonitored fails exactly like a secret.
Federated credentials hold nothing. An external IdP you already trust mints a short-lived token; Entra exchanges it. There is no secret on the app, nothing to rotate, nothing to leak.
The three credential types weighed honestly:
| Dimension | Client secret | Certificate (private_key_jwt) |
Federated credential (FIC) |
|---|---|---|---|
| What you store | A password (string) | A private key | Nothing |
| Leakage risk | High (copy-paste, logs) | Low (key in HSM) | None (no standing credential) |
| Rotation toil | Manual, coordinated, on a timer | Manual, plus CA/revocation | None |
| Max lifetime | 24 months (Entra cap) | Cert validity (you choose) | Per-request, minutes |
| Setup effort | Trivial | Moderate (PKI, distribution) | Low–moderate (trust config) |
| Where it shines | Quick test, last resort | When no workload identity exists | CI, K8s, cross-cloud workloads |
| Failure mode | Expiry / leak | Expiry / mis-issued | subject/aud mismatch |
Using a certificate instead of a secret (the better fallback when federation isn’t possible):
# Upload a public-key cert; the app signs assertions with the matching private key.
az ad app credential reset --id "$APP_ID" \
--cert "@/secure/path/app-cert.pem" --append
The runtime then sends client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and a JWT it signs with the private key — no secret crosses the wire. But where a workload identity exists, skip even this and federate.
Configuring workload identity federation
You attach a federated identity credential (FIC) to the application object. It declares a trust: “I will accept tokens from this issuer, with this subject, for this audience.” Up to 20 FICs per application (and per managed identity), so you scope, you don’t enumerate one-per-repo.
Three fields define the trust:
issuer— the external IdP’s OIDC issuer URL; it must serve/.well-known/openid-configuration.subject— the exactsubclaim Entra requires in the incoming token. This is your scoping lever and it is matched exactly, not by prefix.audiences— what the external token’saudmust be. For Entra this isapi://AzureADTokenExchange.
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-main-deploy",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:kloudvin/platform:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
The same object in Terraform:
resource "azuread_application_federated_identity_credential" "gha_main" {
application_id = azuread_application.oidc_client.id
display_name = "github-main-deploy"
description = "GitHub Actions, main branch"
issuer = "https://token.actions.githubusercontent.com"
subject = "repo:kloudvin/platform:ref:refs/heads/main"
audiences = ["api://AzureADTokenExchange"]
}
The FIC fields and their hard rules:
| Field | What it is | Rule / limit |
|---|---|---|
name |
Unique label for the FIC | Unique per app; ≤ 120 chars |
issuer |
External OIDC issuer URL | Must serve /.well-known/openid-configuration; HTTPS |
subject |
Exact sub to require |
Exact match (no prefix/wildcard) unless using claimsMatchingExpression |
audiences |
Allowed aud of the incoming token |
Single value api://AzureADTokenExchange for Entra |
claimsMatchingExpression |
Flexible matching (preview/GA) | Mutually exclusive with subject |
| (count) | FICs per application | ≤ 20 |
Flexible FICs: matching a class of subjects
When you’d otherwise need a FIC per repo (and blow the 20-credential ceiling), a flexible FIC matches a class of subjects with a claimsMatchingExpression instead of an exact subject. One credential then covers, say, every prod-environment deploy across an org’s repos:
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "gha-prod-environments",
"issuer": "https://token.actions.githubusercontent.com",
"claimsMatchingExpression": {
"value": "claims['"'"'sub'"'"'] matches '"'"'repo:contoso/*:environment:prod'"'"'",
"languageVersion": 1
},
"audiences": ["api://AzureADTokenExchange"]
}'
The constraints that bite when you adopt flexible FICs:
| Constraint | Detail | Consequence if ignored |
|---|---|---|
Mutually exclusive with subject |
A FIC has either subject or claimsMatchingExpression |
Setting both is rejected |
| Org/namespace boundary | The wildcard matches within the issuer’s namespace (e.g. one GitHub org) | A fork in another org silently won’t match |
| Pair with a network control | A wildcard widens who can act as the app | Without CA on the SP, a matched-but-rogue subject can redeem |
languageVersion |
Expression language version (1) |
Wrong/missing version → create fails |
| Still counts toward 20 | Flexible FICs use a slot too | Many flexible FICs can also hit the ceiling |
The subject strings differ per provider, and getting them exactly right is the entire game. The canonical formats:
| Provider | Issuer | subject format (example) |
|---|---|---|
| GitHub Actions (branch) | https://token.actions.githubusercontent.com |
repo:ORG/REPO:ref:refs/heads/main |
| GitHub Actions (environment) | (same) | repo:ORG/REPO:environment:prod |
| GitHub Actions (tag) | (same) | repo:ORG/REPO:ref:refs/tags/v1.2.3 |
| GitHub Actions (PR) | (same) | repo:ORG/REPO:pull_request |
| Kubernetes / AKS | cluster’s serviceAccountIssuer URL |
system:serviceaccount:NAMESPACE:SA-NAME |
| GitLab CI/CD | https://gitlab.com (or self-hosted) |
project_path:GROUP/PROJECT:ref_type:branch:ref:main |
| AWS (role/OIDC) | the trusted OIDC provider URL | the role/identity sub from the AWS token |
| GCP | https://accounts.google.com |
the service account’s sub (numeric unique_id) |
| Terraform Cloud/HCP | https://app.terraform.io |
organization:ORG:project:PROJ:workspace:WS:run_phase:apply |
Wiring an external token to the federated credential
The exchange uses the OAuth2 client credentials grant with a client_assertion — but the assertion is the external IdP’s token, not something you sign. The pattern is identical across providers; only the issuer and how the workload obtains its token differ.
GitHub Actions
Grant the workflow id-token: write, then let the official login action fetch the OIDC token and exchange it. No client-secret anywhere:
permissions:
id-token: write # lets the job request a GitHub OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: prod # changes the OIDC sub to repo:ORG/REPO:environment:prod
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az account show
The action requests a GitHub OIDC token with aud=api://AzureADTokenExchange and POSTs it to Entra. The raw exchange, if you ever do it by hand:
curl -s -X POST \
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d "client_id=${APP_ID}" \
-d "grant_type=client_credentials" \
-d "scope=https://graph.microsoft.com/.default" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=${GITHUB_OIDC_TOKEN}"
The full GitHub-to-Azure path, including the environment subtlety, is in GitHub Actions to Azure with OIDC Federated Credentials.
Kubernetes / AKS
For a pod, trust the cluster’s service-account issuer and pin the subject to the namespace and service account:
# 1) Read the cluster's OIDC issuer URL.
ISSUER=$(az aks show -n aks-prod -g rg-aks --query "oidcIssuerProfile.issuerUrl" -o tsv)
# 2) Trust that issuer for one namespace+SA.
az ad app federated-credential create --id "$APP_ID" --parameters "{
\"name\": \"aks-payments-sa\",
\"issuer\": \"$ISSUER\",
\"subject\": \"system:serviceaccount:payments:checkout-sa\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
On AKS, the Workload Identity webhook projects a token and sets the client/tenant env vars when you annotate the service account:
apiVersion: v1
kind: ServiceAccount
metadata:
name: checkout-sa
namespace: payments
annotations:
azure.workload.identity/client-id: "00000000-0000-0000-0000-000000000000"
AWS, GCP, and any OIDC issuer
The pattern never changes: take the IdP’s issuer from its discovery document, decode a sample token to read the exact sub, set audiences to api://AzureADTokenExchange, and create the FIC. For AWS you federate an IAM role’s web-identity token; for GCP a service account’s identity token; for self-hosted OIDC, whatever your issuer mints. The discipline is the same — never guess the subject; decode a real token and copy it verbatim.
Per-provider wiring summary:
| Provider | How the workload gets its token | What sets the Azure env/client | Subject source of truth |
|---|---|---|---|
| GitHub Actions | azure/login@v2 (or core.getIDToken) |
The login action | Decode the OIDC token in a debug step |
| AKS pods | Workload Identity webhook projects token | SA annotation azure.workload.identity/client-id |
system:serviceaccount:<ns>:<sa> |
| Self-managed K8s | Projected SA token volume | Env: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE |
Cluster issuer + SA sub |
| GitLab CI | id_tokens: with aud set |
Manual exchange or job script | Decode $CI_JOB_JWT_V2-style token |
| AWS | STS/web-identity token of the role | Manual exchange | Decode the AWS OIDC token |
| GCP | Metadata server identity token | Manual exchange | Decode the GCP id token |
FIC vs user-assigned managed identity
There are two routes to secretless Azure-resource access, and the choice matters. A user-assigned managed identity (UAMI) also supports federated credentials (so a UAMI can be the federation target instead of an app registration). Use a UAMI when you only need Azure RBAC and want lifecycle managed by Azure; use an app registration with FICs when you need Graph application permissions, app roles, a multi-tenant identity, or features only the registration exposes. Both are covered in Entra Managed Identities Deep Dive: User-Assigned, FIC, RBAC.
| Aspect | App registration + FIC | User-assigned managed identity + FIC |
|---|---|---|
| Graph app permissions / app roles | Yes | No (RBAC only) |
| Multi-tenant identity | Yes | No (single tenant) |
| Azure RBAC | Yes | Yes |
| Lifecycle | You manage the app object | Azure manages the identity resource |
| Best for | API auth, Graph, multi-tenant SaaS, CI to many targets | Azure-resource access from a known workload |
| FIC limit | 20 | 20 |
Validating tokens: issuer, audience, and signing keys
If you are the resource server receiving Entra access tokens, validate them properly. A JWT you do not validate is a header you trust blindly.
Pull the OIDC discovery document for the right token version:
# v2.0 discovery — gives issuer, jwks_uri, token_endpoint.
curl -s "https://login.microsoftonline.com/${TENANT_ID}/v2.0/.well-known/openid-configuration" \
| jq '{issuer, jwks_uri, token_endpoint}'
Then enforce, in this order:
- Signature — fetch keys from
jwks_uri, match the token’skid, verify the RS256 signature. Cache JWKS and refresh on an unknownkid; keys roll. iss— must equal the discoveryissuer(v2:https://login.microsoftonline.com/{tid}/v2.0; v1:https://sts.windows.net/{tid}/). For multi-tenant, validatetidagainst an allowlist, not just the issuer template.aud— must equal your API’s app ID URI or client ID. Rejecting on audience is what stops a token minted for another app being replayed at yours.exp/nbf— honor expiry and not-before with minimal clock skew.- Authorization — only after the token is authentic: check
scp(delegated) and/orroles(app roles), andidtypto distinguish app-only callers.
The validation checklist as a reference:
| Check | What to compare | Failure means | Common mistake |
|---|---|---|---|
| Signature | kid → JWKS key, RS256 |
Forged/altered token | Not caching/rotating JWKS |
iss |
Discovery issuer (+ tid allowlist for MT) |
Wrong authority/tenant | Trusting …/common issuer literally |
aud |
Your appId / api://… URI |
Token for another app | Skipping aud entirely |
exp/nbf |
Current time ± small skew | Expired / not-yet-valid | Excessive clock skew |
| Version | v1 vs v2 issuer/aud shape | Library can’t validate | Mismatched accessTokenAcceptedVersion |
scp/roles |
Required scope/role present | Under-authorized call | Treating authn as authz |
Two rules that save real incidents: validate access tokens only if they were issued for your API — Graph access tokens are opaque to you and not meant for your validation. And ID tokens authenticate the user to the client; they are not API authorization credentials — never accept an ID token as a bearer token at an API.
The three token types, what each is for, and the cardinal rule for each — because confusing them is a recurring vulnerability:
| Token | Purpose | Who validates it | Cardinal rule |
|---|---|---|---|
| ID token | Authenticate the user to the client | The client that requested sign-in | Never send to an API as a bearer token; validate nonce |
| Access token (your API) | Authorize a call to your resource | Your resource API | Validate signature/iss/aud/exp, then scp/roles |
| Access token (Graph/other) | Authorize a call to that resource | That resource (Graph), not you | Opaque to you; just send it — never parse/validate |
| Refresh token | Obtain new access tokens silently | The token endpoint only | A long-lived credential — store like a secret; bound to client |
Hardening the registration
Federation removes the standing credential, but a few more controls turn “secretless” into “defensible.”
- No standing credentials. Once federation is live, delete every secret and certificate on the app so a fallback path can’t be abused. Federation is only as strong as the absence of a fallback.
- Conditional Access for workload identities. You can target service principals with CA policy — restrict sign-in to known IP ranges so federated tokens are only honored from your CI egress or cluster egress. This is the backstop if a subject pin is ever too broad. See Securing Workload Identities with Conditional Access and Risk.
appRoleAssignmentRequired = trueon the SP so only assigned identities get a token.- Audit-log streaming + FIC alerting. Ship Entra sign-in logs (including the service principal sign-in category) and audit logs to your SIEM, and alert on FIC create/update on sensitive apps and on any new credential added to a high-privilege application.
# List then remove any leftover secrets/certs.
az ad app credential list --id "$APP_ID" -o table
az ad app credential delete --id "$APP_ID" --key-id "<keyId>"
A Conditional Access policy that pins the workload SP to known network locations (target the service principal, scope to your named locations):
# Conceptually: CA policy → users/workload identities = this SP → conditions: locations
# → grant: block unless from named (CI/cluster egress) location.
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" \
--headers 'Content-Type=application/json' \
--body '{
"displayName": "WL - kv-oidc-client - egress only",
"state": "enabledForReportingButNotEnforced",
"conditions": {
"clientApplications": { "includeServicePrincipals": ["'$SP_OBJ_ID'"] },
"locations": { "includeLocations": ["All"], "excludeLocations": ["<namedLocationId>"] }
},
"grantControls": { "operator": "OR", "builtInControls": ["block"] }
}'
The hardening controls and what each defends against:
| Control | Defends against | How to set | Verify |
|---|---|---|---|
| Delete all secrets/certs | Fallback credential abuse | az ad app credential delete |
credential list empty |
| CA for workload SP | Token redeemed from rogue network | CA policy targeting the SP | Sign-in logs show location enforcement |
appRoleAssignmentRequired |
Unassigned identities getting tokens | SP property = true | Token denied without assignment |
| FIC create/update alert | Silent trust expansion | SIEM rule on audit log | Alert fires on test FIC |
| New-credential alert | Re-introducing a secret | SIEM rule on passwordCredentials add |
Alert fires on test secret |
| Least-privilege Graph perms | Over-broad app-only access | Narrow scopes + consent review | appRoleAssignments minimal |
Architecture at a glance
Trace the two halves of this system as they actually run, because the same application object serves both a human and a machine, and the credential each uses is the whole point.
The user path (interactive, auth code + PKCE). A person opens the web app. The server generates a code_verifier and redirects the browser to Entra’s /authorize endpoint with response_type=code, the derived code_challenge (S256), a state, and a nonce. Entra authenticates the user, applies any Conditional Access (MFA, device compliance), and returns an authorization code to the registered HTTPS redirect URI on the web platform. The server then calls /token with the code, the code_verifier, and its client credential — for a user-facing web app this is typically still a secret or, better, a certificate — and receives an ID token (who the user is), an access token (scp/roles for what the app may do as the user), and optionally a refresh token (offline_access). The app validates the ID token’s nonce and uses the access token to call downstream APIs as the user.
The machine path (app-only, secretless). A CI job or a pod needs to act as the same application with no user. Its platform — GitHub Actions, an AKS cluster, AWS, GCP — mints a short-lived OIDC token with aud=api://AzureADTokenExchange and a sub that names the exact trust boundary (repo:org/repo:environment:prod, or system:serviceaccount:payments:checkout-sa). The workload presents that token as the client_assertion in a client-credentials request to Entra’s /token endpoint. Entra matches the incoming token’s issuer/subject/audience against a federated identity credential on the application object — and, finding a match, issues an Entra app-only access token carrying the application permissions (roles, idtyp=app) that admin consent already granted on the service principal. No secret was stored or sent.
Both paths converge on the same three facts: the application object holds the definition (redirect URIs, scopes, FICs, requested permissions), the service principal holds the live grants and is the target of Conditional Access and RBAC, and a resource API validates whatever token arrives (signature, iss, aud, exp, then scp/roles). The method of the whole article is to push the machine path’s credential from a secret you protect, to a certificate you protect, to a federated assertion you hold nothing for — while the user path keeps PKCE and a tightly-scoped, soon-to-be-certificate client credential. Read the system as: definition on the app, identity and consent on the SP, validation at the API, and no standing secret anywhere a workload can prove who it is.
Real-world scenario
Northwind Logistics runs a freight-tracking platform on Azure: a server-side ASP.NET web app for dispatchers (interactive sign-in), a .NET API the web app and partners call, and a fleet of GitHub Actions pipelines plus an AKS cluster of microservices that call Microsoft Graph and the internal API as themselves. The platform team is six engineers. Before this work, the app registration carried eleven client secrets across the API, the pipelines, and three microservices, each on its own 1–2 year timer, tracked in a spreadsheet that was, predictably, three secrets out of date.
The incident that forced the change: at 02:11 UTC on a Sunday, a secret expired and the partner-integration pipeline started failing every Graph call with AADSTS7000215: Invalid client secret provided. The on-call engineer’s first move — generate a new secret in the portal and paste it into the pipeline — worked for that pipeline and broke nothing else, but it taught the wrong lesson (rotate faster) and left ten more secrets ticking. The post-incident review found two of those secrets in plaintext in an old pipeline log and one in a closed support ticket. The platform lead drew the obvious conclusion: the credential was the incident, and the fix was to stop holding one wherever a workload could prove itself.
The migration ran in four moves. First, the GitHub pipelines: they added id-token: write, created FICs for repo:northwind/freight:environment:prod and :environment:staging, switched to azure/login@v2 with no client-secret, and deleted the pipeline secrets. Second, the AKS microservices: they enabled the cluster OIDC issuer, turned on the Workload Identity webhook, created FICs per system:serviceaccount:<ns>:<sa>, annotated the service accounts, and removed those secrets. Third, the API-to-Graph daemon path moved to a FIC against the same AKS issuer. Fourth — the one that bit them — they tried to give every one of forty partner-repo pipelines its own FIC and hit the 20-FIC-per-application ceiling at credential twenty-one with Maximum number of federated identity credentials reached. The fix was a flexible FIC with a claims-matching expression covering repo:northwind/*:environment:prod, paired with a Conditional Access policy scoped to the service principal and GitHub’s egress ranges so a matched-but-rogue subject still couldn’t redeem a token.
The web app kept the auth-code+PKCE flow but swapped its one remaining secret for a certificate issued from the team’s PKI, private key in Key Vault, with a 12-month rotation runbook — because that path has a human, not a workload identity, so federation didn’t apply.
The result: from eleven secrets to zero secrets and one certificate, no rotation timers on any machine path, and a SIEM rule that alerts if any passwordCredentials entry ever reappears on the app. Six months later the on-call rotation has had zero secret-expiry pages, and the quarterly access review — which used to be “reconcile the secret spreadsheet” — is now “confirm the FIC subjects and the CA location policy,” which takes ten minutes. The lesson on the wall: “A secret that can expire is an incident you’ve scheduled. Federate the machines; certificate the humans; hold nothing you don’t have to.”
The migration as a sequence, because the order and the ceiling are the lesson:
| Step | What they did | Credential before → after | What went wrong / watch-out |
|---|---|---|---|
| 1 | GitHub pipelines → FIC (environment:prod/staging) |
4 secrets → 0 | Subject is :environment:, not :ref:, with environment: set |
| 2 | AKS microservices → FIC per SA | 3 secrets → 0 | Must enable cluster OIDC issuer + webhook first |
| 3 | API→Graph daemon → FIC (AKS issuer) | 1 secret → 0 | App-only token; check roles/idtyp at the API |
| 4 | 40 partner pipelines → flexible FIC wildcard | n secrets → 0 | Hit 20-FIC ceiling; switch to claimsMatchingExpression |
| 5 | Web app (human) → certificate | 1 secret → 1 cert | No workload identity; cert + Key Vault + rotation runbook |
| 6 | CA policy + SIEM alerting | — | Pin SP to egress IPs; alert on any new secret |
Advantages and disadvantages
The confidential-client-plus-federation model removes the worst operational failure class in app identity, but it has real edges. Weigh it honestly.
| Advantages | Disadvantages |
|---|---|
| No standing credential on machine paths — nothing to expire, leak, or rotate | Federation only fits workloads with an OIDC identity (CI, K8s, clouds) — a human web flow still needs a client credential |
| Exact subject pinning binds a credential to one trust boundary (one repo+branch, one namespace+SA) | The subject is matched exactly; GitHub environments/tags/PRs each change sub, so a wrong pin fails closed (AADSTS700213) |
| Short-lived federated tokens mean a stolen token is useless in minutes | You depend on the external IdP’s issuer availability and key hygiene |
| Certificates (where federation doesn’t fit) keep the private key in an HSM — no shared password | Certificates still carry a lifecycle (issuance, rotation, revocation, CA trust) |
| Consent and RBAC on the SP give a single, auditable grant surface | The app-vs-SP split is subtle — assigning to the wrong object silently no-ops |
| Conditional Access can pin workload sign-in to known networks | CA for workload identities is a premium capability (Workload Identities Premium) |
| 20 FICs/app plus flexible claims-matching scale to many callers without per-caller secrets | The 20-FIC ceiling forces flexible expressions; naive one-FIC-per-repo hits the wall |
The model is right for any app whose machine callers run on an OIDC-capable platform and any tenant that wants to retire secret-rotation toil. It is less of a fit when callers run somewhere with no OIDC identity at all (rare, but then a certificate is your floor), or when an ISV’s customers can’t be assumed to run modern CI — there, you still ship a certificate path. The disadvantages are all manageable, and every one is cheaper to manage than a 02:00 secret-expiry page.
Hands-on lab
Stand up a confidential app, run the secretless client-credentials flow against Microsoft Graph using a temporary secret, then replace the secret with a federated credential and prove a token issues without it. Free-tier-friendly (no paid SKU required for the registration); run in Cloud Shell (Bash). We delete everything at the end.
Step 1 — Variables and the application object.
APP_NAME="kv-oidc-lab-$RANDOM"
TENANT_ID=$(az account show --query tenantId -o tsv)
az ad app create --display-name "$APP_NAME" --sign-in-audience AzureADMyOrg -o table
APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv)
APP_OBJ_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].id" -o tsv)
echo "appId=$APP_ID objId=$APP_OBJ_ID"
Expected: a table row for the app; APP_ID is a GUID.
Step 2 — Materialize the service principal.
az ad sp create --id "$APP_ID" -o table
SP_OBJ_ID=$(az ad sp show --id "$APP_ID" --query id -o tsv)
echo "spObjId=$SP_OBJ_ID"
Expected: an SP row; SP_OBJ_ID is a GUID distinct from APP_OBJ_ID — that distinctness is the whole app-vs-SP lesson.
Step 3 — Add an application permission and consent it. We grant the low-impact User.Read.All (application) so the daemon can read directory users.
GRAPH_APP_ID="00000003-0000-0000-c000-000000000000"
az ad app permission add --id "$APP_ID" --api "$GRAPH_APP_ID" \
--api-permissions df021288-bdef-4463-88db-98f22de89214=Role
az ad app permission admin-consent --id "$APP_ID"
Expected: no error. Verify the grant landed on the SP:
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJ_ID/appRoleAssignments" \
--query "value[].resourceDisplayName" -o tsv
# Expect: Microsoft Graph
Step 4 — Create a TEMPORARY secret and run client credentials. (We delete this secret in step 6 — the point is to feel the before/after.)
SECRET=$(az ad app credential reset --id "$APP_ID" --append --query password -o tsv)
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d "client_id=${APP_ID}" \
-d "grant_type=client_credentials" \
-d "scope=https://graph.microsoft.com/.default" \
-d "client_secret=${SECRET}" | jq -r .access_token)
# Use the app-only token to read users (proves the permission + consent work).
curl -s -H "Authorization: Bearer $TOKEN" \
"https://graph.microsoft.com/v1.0/users?\$top=1&\$select=displayName" | jq .
Expected: a JSON object with one user’s displayName. Decode the token at jwt.ms and confirm idtyp=app and roles includes User.Read.All — that is the app-only fingerprint.
Step 5 — Add a federated identity credential. We trust GitHub’s issuer for a sample repo/branch (you don’t need a real repo to create the FIC; we’re proving the trust object, then we’ll confirm secret deletion).
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "lab-gha-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:kloudvin/lab:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
az ad app federated-credential list --id "$APP_ID" \
--query "[].{name:name, issuer:issuer, subject:subject, aud:audiences[0]}" -o table
Expected: a row showing your issuer/subject and api://AzureADTokenExchange. In a real pipeline, azure/login@v2 with id-token: write would now obtain a token with no secret.
Step 6 — Delete the secret; prove the FIC remains (secretless state).
KEYID=$(az ad app credential list --id "$APP_ID" --query "[0].keyId" -o tsv)
az ad app credential delete --id "$APP_ID" --key-id "$KEYID"
# Expect EMPTY — no standing credential remains.
az ad app credential list --id "$APP_ID" -o table
# FIC still present — the secretless trust survives.
az ad app federated-credential list --id "$APP_ID" --query "[].name" -o tsv
Expected: the credential list is empty; the FIC name still prints. That is the destination — zero secrets, one declared trust.
Step 7 — Validation summary.
echo "Secrets/certs (expect empty):"; az ad app credential list --id "$APP_ID" -o table
echo "FICs (expect lab-gha-main):"; az ad app federated-credential list --id "$APP_ID" --query "[].name" -o tsv
echo "App-role grants on SP (expect Microsoft Graph):"; \
az rest --method GET --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJ_ID/appRoleAssignments" --query "value[].resourceDisplayName" -o tsv
Step 8 — Teardown.
az ad app delete --id "$APP_ID" # removes the app and its SP
The lab’s checkpoints, so you know each step proved something:
| Step | Proves | Pass signal |
|---|---|---|
| 2 | App and SP are distinct objects | Two different object IDs |
| 3 | Consent lands on the SP, not the app | appRoleAssignments shows Microsoft Graph |
| 4 | App-only token works; idtyp=app |
User JSON returns; jwt.ms shows roles |
| 5 | FIC declares a secretless trust | FIC row with api://AzureADTokenExchange |
| 6 | Secretless state is real | Credential list empty, FIC remains |
Command reference for this build
Keep this open while you work — every operation in the article mapped to its exact az/Graph command, so you’re not hunting through the prose mid-task:
| Task | Command |
|---|---|
| Create application object | az ad app create --display-name N --sign-in-audience AzureADMyOrg |
| Materialize service principal | az ad sp create --id $APP_ID |
| Set web redirect URI | az ad app update --id $APP_ID --web-redirect-uris https://…/callback |
| Set Application ID URI | az ad app update --id $APP_ID --identifier-uris api://$APP_ID |
| Add delegated permission | az ad app permission add --id $APP_ID --api $GRAPH --api-permissions <id>=Scope |
| Add application permission | az ad app permission add --id $APP_ID --api $GRAPH --api-permissions <id>=Role |
| Grant admin consent | az ad app permission admin-consent --id $APP_ID |
| List delegated grants (SP) | az ad app permission list-grants --id $APP_ID |
| List app-role grants (SP) | az rest GET /servicePrincipals/$SP_OBJ_ID/appRoleAssignments |
| Upload certificate credential | az ad app credential reset --id $APP_ID --cert @cert.pem --append |
| Create federated credential | az ad app federated-credential create --id $APP_ID --parameters '{…}' |
| List federated credentials | az ad app federated-credential list --id $APP_ID |
| List secrets/certs | az ad app credential list --id $APP_ID |
| Delete a secret/cert | az ad app credential delete --id $APP_ID --key-id <keyId> |
| Read AKS OIDC issuer | az aks show -n C -g RG --query oidcIssuerProfile.issuerUrl -o tsv |
| Pull OIDC discovery (v2) | curl …/{tenant}/v2.0/.well-known/openid-configuration |
| Delete app (and SP) | az ad app delete --id $APP_ID |
Common mistakes & troubleshooting
The failures here are specific and the error strings are diagnostic. Match your symptom to the row, confirm with the exact check, apply the fix.
| # | Symptom | Root cause | Confirm (exact check) | Fix |
|---|---|---|---|---|
| 1 | AADSTS700213: No matching federated identity record found for presented assertion subject |
Incoming token sub ≠ FIC subject byte-for-byte |
Decode the OIDC token (jwt.ms); compare sub to FIC subject |
Copy the real sub verbatim; for GitHub environments use :environment:<env> |
| 2 | AADSTS700016: Application not found in the directory |
Using appId of an app with no SP in this tenant (multi-tenant not consented) |
az ad sp show --id $APP_ID → not found |
Run admin consent / az ad sp create --id $APP_ID in the tenant |
| 3 | AADSTS7000215: Invalid client secret provided |
Secret expired, wrong, or has trailing whitespace | az ad app credential list --id $APP_ID shows expiry |
Rotate — or better, switch to a FIC and delete secrets |
| 4 | AADSTS50011: redirect URI ... does not match |
Request redirect_uri not registered exactly (slash, scheme, platform) |
Compare request URI to az ad app show --query web.redirectUris |
Register the exact URI under the correct platform |
| 5 | App-only call returns 403/Insufficient privileges |
Permission requested but not consented (grant absent on SP) | az ad app permission list-grants / appRoleAssignments empty |
az ad app permission admin-consent --id $APP_ID |
| 6 | Wrong access level (too much/little) | =Scope used where =Role meant, or vice-versa |
Token: delegated shows scp, app-only shows roles/idtyp=app |
Re-add with correct suffix; re-consent; verify claim |
| 7 | AADSTS650057: Invalid resource / wrong aud |
Requested a resource the app has no permission for, or wrong .default target |
Inspect requested scope; decode token aud |
Request <resource>/.default; ensure permission consented |
| 8 | OBO fails: AADSTS500131 / consent error on downstream |
Middle-tier API lacks downstream permission or knownClientApplications |
Check API’s delegated perms to the downstream; check knownClientApplications |
Add downstream delegated perm; set knownClientApplications; consent |
| 9 | Token validates in one lib, fails in another | v1 vs v2 mismatch (iss/aud shape) |
Decode iss: sts.windows.net (v1) vs …/v2.0 |
Set requestedAccessTokenVersion: 2; validate against matching issuer |
| 10 | Maximum number of federated identity credentials reached |
More than 20 FICs on one app | az ad app federated-credential list count > 20 |
Consolidate with a flexible FIC (claimsMatchingExpression) |
| 11 | groups claim missing for some users |
Group overage (user in > claim cap) | Token has _claim_names/_claim_sources, no groups |
Emit app roles instead, or call Graph for full membership |
| 12 | Federated token rejected with aud error |
External token aud ≠ api://AzureADTokenExchange |
Decode the external OIDC token’s aud |
Set the workflow/SA aud to api://AzureADTokenExchange |
| 13 | Works locally, fails in container with secret in env | Secret leaked into image/log; or wrong env var name | Inspect env/log for the secret string | Move to FIC; scrub the secret; rotate immediately |
| 14 | CA policy “doesn’t apply” to the workload | Policy targets the application ID, not the SP | CA policy clientApplications references app, not SP object |
Re-target the service principal object ID |
The Entra error-code reference for this domain, distilled:
| Code | Meaning | Most likely cause |
|---|---|---|
AADSTS700213 |
No matching FIC for the assertion subject | subject mismatch (environment/tag/PR) |
AADSTS700024 |
Client assertion expired / invalid | Stale or malformed external token |
AADSTS7000215 |
Invalid client secret | Secret expired/wrong |
AADSTS7000222 |
Client secret expired | Expiry timer hit |
AADSTS700016 |
App not found in directory | No SP / multi-tenant not consented |
AADSTS50011 |
Redirect URI mismatch | Unregistered/wrong-platform URI |
AADSTS65001 |
User/admin consent not granted | Missing consent grant |
AADSTS650057 |
Invalid resource | Permission not consented for resource |
AADSTS90009 |
App requesting a token for itself | Misconfigured audience/.default |
AADSTS500131 |
OBO downstream consent issue | Missing downstream perm / known client app |
A fast decision table for the federation-specific failures, because at 02:00 you want the branch, not the essay:
| If you see… | It’s probably… | Do this |
|---|---|---|
700213 on a GitHub job with an environment: |
sub is :environment:<env>, FIC pinned to :ref: |
Recreate the FIC with the :environment:<env> subject |
700213 on a tag/release pipeline |
sub is :ref:refs/tags/<tag> |
Add a FIC (or flexible FIC) for the tag pattern |
700213 only on PR builds |
sub is repo:org/repo:pull_request |
Add a pull_request FIC, or don’t federate PRs |
Maximum number of federated identity credentials reached |
More than 20 FICs on the app | Consolidate to a flexible FIC (claimsMatchingExpression) |
| Federation works in one repo, not a fork in another org | Flexible FIC org boundary; fork is outside the org namespace | Keep callers in-org, or add an explicit FIC |
External token aud rejected |
aud ≠ api://AzureADTokenExchange |
Set the workflow/SA audience to api://AzureADTokenExchange |
| Token issued but RBAC/Graph still 403 | Token is fine; the SP lacks the role/consent | Assign RBAC / grant admin consent on the SP |
A few of the highest-value confirmations spelled out. For a federation subject mismatch (#1), add a one-off debug step in the workflow to print the token’s subject before fixing the FIC:
# In a pipeline debug step, decode the OIDC token's payload to read the real sub.
echo "$OIDC_TOKEN" | cut -d '.' -f2 | base64 -d 2>/dev/null | jq '{aud, iss, sub}'
# Then set the FIC subject to EXACTLY the printed sub.
For a consent gap (#5), the decisive check is the grant on the SP, not the request on the app:
az ad app permission list-grants --id "$APP_ID" -o table # delegated grants
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJ_ID/appRoleAssignments" -o json # app-role grants
Best practices
- Treat the registration as code. Define app, SP, permissions, scopes, app roles, and FICs in Terraform or a reviewed Graph script. Hand-edited manifests rot and corrupt.
- Default to single-tenant. Set
signInAudience: AzureADMyOrgunless you have a real multi-tenant requirement; widening the audience widens validation and consent surface. - Auth code + PKCE only for interactive flows. Disable implicit grant (
access_token/id_tokenissuance off). UseS256,state, andnonceevery time. - Prefer delegated over application permissions where a user is present; reach for the narrowest Graph scope; never use
Directory.ReadWrite.Allwhen a targeted permission exists. - Verify consent on the SP, not the app. “Requested” ≠ “granted.” Check
oauth2PermissionGrantsandappRoleAssignments. - Federate every machine; certificate every human-bound confidential client. No client secrets in CI, clusters, or daemons; certificates (key in HSM) only where federation can’t reach.
- Pin FIC subjects exactly, and scale with flexible FICs before you hit the 20-credential ceiling — never one-FIC-per-repo by reflex.
- Set
appRoleAssignmentRequired = trueon APIs so only assigned identities can get a token. - Validate every token at the resource (signature,
iss,aud,exp/nbf, version) and only then authorize onscp/roles. - Apply Conditional Access to workload SPs so federated tokens are honored only from known egress networks.
- Delete standing credentials after federation and alert on any reappearance of
passwordCredentials/keyCredentialson privileged apps. - Use app roles, not raw group claims, for authorization to avoid the group-overage trap.
Security notes
The threat model for a confidential client is dominated by credential compromise and over-privilege, and federation plus least privilege addresses both.
- Least privilege on permissions. Application permissions ignore the user and are tenant-wide — every one is a standing capability of the app’s identity. Grant the minimum, scope app-only access with application access policies / RSC where supported, and review
appRoleAssignmentsquarterly. See Entra OAuth Consent Governance and App Permission Hardening. - No standing secrets. A secret is a bearer credential anyone who finds it can use until it expires. Federation removes it entirely on machine paths; certificates keep the private key in an HSM where it never leaves. After migrating, the absence of a fallback is the control — delete secrets and alert on their return.
- Network isolation for the token redemption. Conditional Access targeting the service principal pins federated sign-in to your CI/cluster egress, so a leaked-but-pinned subject still can’t redeem from the open internet. This is the backstop for an over-broad subject. See Securing Workload Identities with Conditional Access and Risk.
- Token validation is an authorization boundary. Skipping
audlets a token minted for another app be replayed at yours; skipping signature lets a forged token in; accepting an ID token at an API confuses authentication with authorization. Validate in order and authorize only after. - Subject precision is a security property, not a nuisance. An exact
subject(one repo+branch, one namespace+SA) is the trust boundary. A wildcard flexible FIC must be paired with a network/CA control or you’ve widened who can act as the app. - Audit and detect. Stream sign-in (including SP sign-in) and audit logs to a SIEM; alert on FIC create/update and any new credential on high-privilege apps; correlate with Troubleshooting Managed Identity Token Acquisition 403s patterns when workloads fail to get tokens.
Cost & sizing
The registration itself is free — application objects, service principals, secrets, certificates, and federated credentials cost nothing to create. The costs are operational and licensing-adjacent, and they tilt the build toward federation.
- Conditional Access for workload identities requires Microsoft Entra Workload Identities Premium, licensed per service principal per month (roughly $3/SP/month, ~₹250). Budget it only for the SPs you actually protect with CA/risk — not every app needs it, but your high-privilege machine identities do.
- Certificates carry PKI cost: a CA (internal or a public CA’s per-cert fee), Key Vault to hold private keys (Key Vault standard ~₹0.03 per 10,000 operations plus a small per-secret/cert footprint), and the engineering time of a rotation runbook. Federation has none of this on machine paths.
- The hidden cost federation removes is the rotation toil and incident risk of secrets: every secret is an expiry timer and a leak vector, and a single 02:00 expiry page costs more engineer-hours than the entire CA-licensing line for a year. The cheapest credential is the one you don’t hold.
- Sizing the FIC count: with 20 FICs per app, enumerate only when you have ≤ ~15 stable callers; beyond that, switch to flexible FICs with claims-matching so one credential covers a class. Plan the boundary up front so you don’t hit the ceiling mid-rollout.
A rough monthly picture for a platform protecting its machine identities:
| Item | Quantity | Approx cost | Note |
|---|---|---|---|
| App registration / SP / FIC | Any | ₹0 | Free directory objects |
| Workload Identities Premium (CA on SPs) | per protected SP | ~₹250 / SP / mo | Only for SPs you guard with CA/risk |
| Key Vault (cert/secret for human-bound client) | per app | a few ₹ / mo | Operations + footprint; tiny |
| Public CA cert (optional) | per cert | varies (₹0 internal CA) | Internal PKI is free; public CA per-cert |
| Secret-rotation toil (avoided) | — | high (engineer-hours) | The cost federation removes |
Interview & exam questions
- What is the difference between the application object and the service principal? The application object (
/applications) is the global definition in the home tenant (redirect URIs, scopes, FICs, requested permissions), referenced byappId. The service principal (/servicePrincipals) is the local instance in a tenant that signs in, holds role assignments and consent grants, and is the target of Conditional Access and RBAC. Single-tenant apps have one of each; multi-tenant apps have one app and an SP per consenting tenant. Maps to SC-300 application-integration objectives. - When do you use client credentials vs authorization code with PKCE? Client credentials when there is no user — a daemon, CI, or service acting as itself, consuming application permissions and getting an app-only token. Auth code + PKCE when a user signs in interactively; PKCE protects the code in transit and the confidential client still authenticates at the token endpoint. SPAs use PKCE without a secret; web apps use both.
- What is the on-behalf-of flow for? A middle-tier API that received a user’s access token needs to call a further downstream API as that same user, preserving their identity and permissions. It exchanges the incoming user token for a new token for the downstream resource, requiring the API to have the downstream delegated permission (and often
knownClientApplicationsset). Without OBO you’d lose the user context or fall back to app-only. - Why are client secrets a problem and what replaces them? They are passwords: they expire (24-month cap), leak into logs and CI, and rotation is coordinated toil. Replace with certificates (
private_key_jwt, private key in an HSM) where a human-bound client needs a credential, and with workload identity federation (federated credentials) for machines — an external IdP mints a short-lived token Entra exchanges, so nothing is stored. - What three fields define a federated identity credential and which is the scoping lever?
issuer(the external OIDC issuer),subject(the exactsubrequired — the scoping lever, matched byte-for-byte), andaudiences(api://AzureADTokenExchangefor Entra). The subject pins the credential to one trust boundary (one repo+branch, one namespace+SA). - What’s the difference between delegated and application permissions? Delegated (
Scope) acts as the signed-in user; effective access is the intersection of the app’s grant and the user’s rights; appears inscp. Application (Role) acts as the app itself, exactly what’s granted tenant-wide, always requires admin consent, appears inroles.=Scopevs=Roleselects the class inaz ad app permission add. - How do you validate an Entra access token at your API? Verify the signature against the tenant’s JWKS (match
kid, RS256),issagainst the discovery issuer (with atidallowlist for multi-tenant),audagainst your app ID URI/client ID,exp/nbffor time, the token version (v1 vs v2), then authorize onscp/roles. Never validate a Graph token (opaque to you) or accept an ID token as an API bearer. - What is the FIC limit and how do you scale past it? 20 federated credentials per application (and per managed identity). Past that, use a flexible FIC with a
claimsMatchingExpression(e.g.claims['sub'] matches 'repo:org/*:environment:prod') so one credential covers a class of subjects — but pair a wildcard with Conditional Access scoped to the SP’s egress. - What does
appRoleAssignmentRequired = truedo? Set on the service principal of an API, it makes the API allowlist-by-default: only users/groups/SPs explicitly assigned an app role can obtain a token for it. No assignment, no token — a strong access boundary for sensitive APIs. - Why might a
groupsclaim be missing and what’s the fix? Group overage — when a user is in more groups than the claim cap (~200 for access tokens), Entra emits a_claim_names/_claim_sourcespointer instead of the groups, and you must call Graph to enumerate. The robust fix is to emit app roles (assigned, bounded) for authorization instead of raw group IDs. - How do you make Conditional Access apply to a workload? Target the service principal (not the application) in the policy’s
clientApplications, and scope conditions (e.g. named locations) so the workload’s federated sign-in is honored only from known egress. This requires Workload Identities Premium. A common bug is targeting the application object, so the policy never matches. - What’s the security risk of a multi-tenant app, and how do you mitigate it? Any consenting tenant gets an SP, and your token validation must handle per-tenant issuers; a naive “trust the issuer template” accepts tokens from tenants you didn’t intend. Mitigate by validating
tidagainst an allowlist, requiring verified publisher, and scoping consent — covered in consent-governance practice.
Quick check
- Which object holds federated identity credentials — the application or the service principal, and which object is the target of a Conditional Access policy?
- In the client-credentials flow against Graph, what
scopedo you request, and why can’t you request individual app permissions at runtime? - A GitHub Actions job using a deployment environment fails federation with
AADSTS700213. What is the most likely cause and where do you read the truth? - You added
User.Read.Allas an application permission but app-only calls still return 403. What single state are you missing and where is it recorded? - As a resource API, name the four authenticity checks you perform on an incoming access token before you check
scp/roles.
Answers
- The application object holds federated credentials; the service principal is the CA target (and the holder of role assignments and consent grants). Targeting the application in a CA policy is a common no-op bug.
- You request
https://graph.microsoft.com/.default. The.defaultscope means “all application permissions already consented for this app on this resource” — the v2 client-credentials flow has no dynamic scopes; you get exactly what admin consent granted, nothing requestable at runtime. - The FIC
subjectdoesn’t match the token’ssub: with an environment set, GitHub’ssubbecomesrepo:ORG/REPO:environment:<env>, not…:ref:refs/heads/main. Decode the OIDC token (jwt.ms or a debug step) and copysubverbatim into the FIC. - Admin consent — the grant on the service principal (an
appRoleAssignment). Adding the permission only writes a request on the app; until the grant lands on the SP, tokens don’t carry it. Runaz ad app permission admin-consentand verifyappRoleAssignments. - Signature (JWKS
kid, RS256),iss(discovery issuer,tidallowlist for multi-tenant),aud(your app ID URI / client ID), andexp/nbf(time, minimal skew). Only after all four pass do you authorize onscp/roles.
Glossary
- Application object — the global definition of an app (
/applications) in its home tenant; holds redirect URIs, exposed scopes, app roles, requested permissions, and federated credentials; referenced byappId. - Service principal (SP) — the local instance of an application in a tenant (
/servicePrincipals); the identity that signs in, holds role assignments and consent grants, and is the target of Conditional Access and RBAC. - Confidential client — an OAuth2 client that can keep a credential confidential (server app, API, daemon, CI) and authenticates itself at the token endpoint; contrast with a public client (SPA/mobile).
- Authorization code + PKCE — the interactive sign-in flow: exchange a one-time code (protected by a
code_verifier/code_challenge) plus the client credential for tokens; the only flow to ship for user sign-in. - Client credentials flow — app-only flow with no user; the app authenticates as itself and receives an app-only token carrying application permissions (
roles). - On-behalf-of (OBO) — a middle-tier API exchanges an incoming user token for a token to a further downstream API, preserving the user’s identity.
- Delegated permission (
Scope) — the app acts as the signed-in user; effective access is the intersection of the app’s grant and the user’s rights; appears inscp. - Application permission (
Role) — the app acts as itself, tenant-wide, exactly as granted; always requires admin consent; appears inroles. - Admin consent — a tenant-wide grant of requested permissions, recorded on the service principal; required for application permissions and high-risk delegated scopes.
- App role — a role you define on your app and assign to users/groups/SPs; appears in the
rolesclaim; models user and app-only authorization. - Scope (exposed) — a delegated permission your API publishes; appears in the caller’s
scpclaim; user- or admin-consentable. - Client secret — a shared password credential (Entra caps new ones at 24 months); a leak/expiry risk; the thing federation removes.
- Certificate credential (
private_key_jwt) — the app signs a JWT assertion with a private key (ideally HSM-held); no shared password, but a key lifecycle. - Federated identity credential (FIC) — a trust on the application to an external OIDC issuer (
issuer/subject/audiences); enables secretless sign-in; ≤ 20 per app. - Flexible FIC — a FIC using a
claimsMatchingExpressioninstead of an exactsubject, to cover a class of subjects (mutually exclusive withsubject). - Workload identity federation — the mechanism by which an external IdP’s short-lived token is exchanged by Entra for an Entra token, so no secret is stored.
api://AzureADTokenExchange— the requiredaudienceof the external token presented to Entra during federation.- Conditional Access for workload identities — CA policy targeting a service principal (e.g. pinning to named network locations); requires Workload Identities Premium.
Next steps
- Read the object-model primer if the app-vs-SP split is still fuzzy: App Registrations vs Enterprise Applications in Entra ID, then the flow mechanics in OIDC and OAuth2 Flows in Entra ID: Authorization Code with PKCE.
- Go deeper on claims and the OBO flow with Entra ID Token Claims, App Roles, and the On-Behalf-Of Flow.
- Operationalize secretless CI across every system in Workload Identity Federation for Secretless CI/CD and the GitHub specifics in GitHub Actions to Azure with OIDC Federated Credentials.
- Choose between an app registration and a managed identity for Azure-resource access in Entra Managed Identities Deep Dive: User-Assigned, FIC, RBAC, and Managed Identity: System- vs User-Assigned Patterns.
- Lock the perimeter around your workload identities with Securing Workload Identities with Conditional Access and Risk and govern app permissions with Entra OAuth Consent Governance and App Permission Hardening.