Identity Azure

Building a Secure OIDC Confidential Client in Entra ID: App Registrations, Secrets, and Workload Identity Federation

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:

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:

  1. The app generates a code_verifier (a high-entropy random string, 43–128 chars) and derives code_challenge = BASE64URL(SHA256(code_verifier)).
  2. The browser is redirected to /authorize with response_type=code, the code_challenge, and code_challenge_method=S256.
  3. The user authenticates (and satisfies any Conditional Access); Entra returns an authorization code to the registered redirect URI.
  4. The backend exchanges the code at /token, presenting the code_verifier and 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:

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:

  1. Signature — fetch keys from jwks_uri, match the token’s kid, verify the RS256 signature. Cache JWKS and refresh on an unknown kid; keys roll.
  2. iss — must equal the discovery issuer (v2: https://login.microsoftonline.com/{tid}/v2.0; v1: https://sts.windows.net/{tid}/). For multi-tenant, validate tid against an allowlist, not just the issuer template.
  3. 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.
  4. exp / nbf — honor expiry and not-before with minimal clock skew.
  5. Authorization — only after the token is authentic: check scp (delegated) and/or roles (app roles), and idtyp to 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.”

# 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 audapi://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 audapi://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

Security notes

The threat model for a confidential client is dominated by credential compromise and over-privilege, and federation plus least privilege addresses both.

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.

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

Quick check

  1. Which object holds federated identity credentials — the application or the service principal, and which object is the target of a Conditional Access policy?
  2. In the client-credentials flow against Graph, what scope do you request, and why can’t you request individual app permissions at runtime?
  3. 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?
  4. You added User.Read.All as an application permission but app-only calls still return 403. What single state are you missing and where is it recorded?
  5. As a resource API, name the four authenticity checks you perform on an incoming access token before you check scp/roles.

Answers

  1. 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.
  2. You request https://graph.microsoft.com/.default. The .default scope 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.
  3. The FIC subject doesn’t match the token’s sub: with an environment set, GitHub’s sub becomes repo:ORG/REPO:environment:<env>, not …:ref:refs/heads/main. Decode the OIDC token (jwt.ms or a debug step) and copy sub verbatim into the FIC.
  4. 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. Run az ad app permission admin-consent and verify appRoleAssignments.
  5. Signature (JWKS kid, RS256), iss (discovery issuer, tid allowlist for multi-tenant), aud (your app ID URI / client ID), and exp/nbf (time, minimal skew). Only after all four pass do you authorize on scp/roles.

Glossary

Next steps

Entra IDOIDCOAuth2App RegistrationWorkload Identity FederationFederated CredentialsService PrincipalConditional Access
Need this built for real?

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

Work with me

Comments

Keep Reading