Dinesh/ Blog
application engineering

MS Stack Ch 14 — Identity for cloud apps

Entra ID, OAuth 2.0 + OIDC, Easy Auth (App Service Authentication), Managed Identity, On-Behalf-Of flow, certificate-based authentication. The identity stack that lets you stop storing passwords or secrets.

27 min readChecking device speech…
Lesson preparation & details

Level: intermediate

By the end, you should be able to

  • Distinguish application objects, service principals and token purposes
  • Validate issuer, audience and time before tenant, scope and resource authorization
  • Design delegated OBO separately from managed workload identity
  • Use explicit versioned credential chains and narrow federation trust

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 32 sections

Identity and library contract

The token-validation examples use ASP.NET Core 8 and Microsoft.Identity.Web's Entra validation integration. The browser example uses the MSAL Browser 3.28.1 initialization contract, with dependencies bundled by the application rather than a floating CDN script. Credential-chain order is checked against Azure.Identity 1.13.2. Entra/App Service protocol docs are timestamped service contracts, not immutable service releases. No tenant registration, login, token acquisition, .NET execution or Azure role change was performed.

Chapter 14 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

Identity is the single topic that breaks more tutorial projects on the way to production than anything else. Storing username and password in a column is the easy mode that does not survive contact with real users, real corporate single sign-on, real auditors, or real attackers. The replacement on the Microsoft stack is an entire vocabulary: Entra ID tenants, app registrations, OAuth 2.0 grants, OIDC ID tokens, audiences, scopes, roles, managed identities, on-behalf-of flows, federated credentials. None of it is hard once you have the model; all of it is bewildering before.

Shipping-level identity means: your SPA uses MSAL with PKCE, your API validates JWTs with AddJwtBearer, and your backend calls Azure via a managed identity instead of a client secret. Expert-level identity means: you can design multi-tenant token validation with per-tenant issuer rules, you understand exactly which token an aud mismatch is pointing at, you can wire OBO with a token cache, and you have audited the OAuth attack surface (open redirects, scope creep, refresh-token theft) on at least one app you shipped.

You finish this chapter when you can stand up an SPA + API + downstream-call scenario end to end without copy-pasting code, and you can explain an audience mismatch using locally inspected synthetic claims while leaving signature/issuer/lifetime validation to a supported library. Decoding is not validation.

Speak OAuth/OIDC — Distinguish token purpose by the protocol and recipient; do not assume every access token is a readable JWT or every claim is present.

Pick the right flow — Authorization code with PKCE for SPAs; client credentials for daemons; OBO for delegated downstream access, not unrestricted impersonation.

Validate tokens correctly — Signature + iss + aud + exp; never disable defaults.

Eliminate secrets — Managed identities and federated credentials wherever possible.

Trust Easy Auth deliberately — Know which headers it injects and how to verify them safely.

Master DefaultAzureCredential — Know the probe order, when it bites you, and when to override it.

Concepts and depth

Entra ID: tenant, app registration, service principal, enterprise application

A tenant is your organisation's Entra directory — contoso.onmicrosoft.com plus any custom domains you have verified. Objects belong to a directory; guest user objects can represent a person whose home account is in another tenant.

An app registration is the definition of an application — its display name, its allowed redirect URIs, the API it exposes, the scopes it requires, the secrets/certificates it can authenticate with. App registrations live in the developer's tenant. Globally, every app registration is identified by a stable GUID, the clientId (also called appId).

An application-type service principal is the runtime identity of an app registration inside a tenant. When you create an app registration through the portal, Entra creates a matching service principal in your home tenant. When another tenant gives consent to that app, a new service principal is created in their tenant — the same app, a different identity per tenant. This is how multi-tenant apps work.

An enterprise application is the admin view of a service principal. App registrations manages the application object; Enterprise applications manages a service principal object in this tenant. These are related but distinct objects with different object IDs. A managed-identity service principal is a different type and has no corresponding application object. Portal registration normally creates both locally; an application object created through other APIs may require explicit service-principal creation.

Good enough to ship

• One app registration per logical app. • Know the difference between clientId, tenantId, objectId. • Use the Enterprise application blade to see who has consented.

Expert tier

• Manage app registrations via Bicep/Terraform; review changes in code. • Use Conditional Access targeting the app's service principal. • Run regular access reviews on consented apps.

OAuth 2.0 and OIDC: the grants you actually use

OAuth 2.0 is an authorisation framework: a client (your app) obtains a token from an authorisation server (Entra) that lets it call a resource (an API) on behalf of a user or itself. OpenID Connect (OIDC) sits on top of OAuth 2.0 and adds an authentication layer: an ID token that says "this is who the user is".

Of the half-dozen specified grant types, only three matter day-to-day:

  • Authorization code with PKCE — the recommended flow for SPAs and native apps to get a user token. The user authenticates against Entra, Entra redirects back with an authorization code, the client exchanges the code (plus a PKCE verifier) for tokens. A public SPA cannot keep a client secret. PKCE binds code redemption to the initiating client's verifier and is required for the supported Entra SPA code flow; it does not turn the browser into a confidential client or replace server client authentication.
  • Client credentials — used by daemons and machine-to-machine calls. The app authenticates with its own credentials (a certificate or — please don't — a secret) and gets a token that represents the app (not a user). The token represents the application; authorize its expected app identity and application permissions, not a presumed human subject.
  • On-behalf-of (OBO) — used by middle-tier APIs. The API received a token from the user and needs to call a downstream API as that user. The API exchanges the incoming token for a downstream token; the downstream sees the original user as the subject.
User → SPA ←──── interactive login ────→ Entra │ │ │ ← access_token (PKCE auth code) ─────┘ ▼ API ←──── on-behalf-of exchange ───→ Entra │ │ │ ← downstream token (user subj) ──────┘ ▼ Downstream
User→SPA; SPA↔Entra interactive login; Entra returns access token via PKCE auth-code flow. SPA→API; API↔Entra on-behalf-of exchange; Entra returns downstream token with user subject; API→Downstream.
Figure text and layout
User → SPA   ←──── interactive login ────→   Entra
        │                                       │
        │  ← access_token (PKCE auth code) ─────┘
        ▼
       API   ←──── on-behalf-of exchange ───→   Entra
        │                                       │
        │  ← downstream token (user subj) ──────┘
        ▼
   Downstream
Text diagram

Prefer authorization code with PKCE to implicit and avoid ROPC, which exposes a password to the client and conflicts with many modern authentication controls. Device code is a supported flow for input-constrained tools, not an obsolete synonym for ROPC; mitigate phishing and use it only in appropriate scenarios.

ID tokens vs access tokens vs refresh tokens

Three token types, three jobs.

  • ID token — proves who the user is to the client. Issued only by OIDC flows. Can contain user claims such as sub, oid, preferred_username, name and email, depending on token version, scopes and optional claims. Email/name/username are not guaranteed, immutable authorization identifiers. Audience is the client (aud == clientId). The API should not consume ID tokens.
  • Access token — proves what the bearer is allowed to do to a resource. Audience is the API (aud == api://... or the API's clientId). Delegated tokens can contain scp; roles can represent app-only permissions or user-assigned app roles. Endpoint policy must distinguish the intended caller/token type. Lifetime varies; Entra commonly randomizes default access-token lifetime over 60–90 minutes, with exceptions. Use the library's token response/cache rather than a hardcoded hour.
  • Refresh token — long-lived credential exchanged at the token endpoint for a new access token. In a browser SPA, MSAL owns its configured memory/session/local storage cache; JavaScript cannot set an HttpOnly cookie. In a BFF, server-side tokens can stay off the browser while the browser holds an HttpOnly, Secure session cookie. Refresh tokens go to the identity token endpoint, not your resource API; revocation and expiry still require a reauthentication path.
// Access token payload (decoded)
{
  "aud": "11111111-1111-1111-1111-111111111111", // API client ID for this v2 token
  "iss": "https://login.microsoftonline.com/<tid>/v2.0",
  "iat": 1717200000,
  "exp": 1717203600,
  "sub": "GUID",                        // stable subject id
  "oid": "GUID",                        // Entra-specific object id
  "tid": "GUID",                        // tenant id
  "preferred_username": "alice@contoso.com",
  "scp": "access_as_user",             // scopes belonging to THIS resource
  "roles": ["Admin"]                    // assigned app roles can also appear for users
}

A common token-purpose bug is sending an ID token to an API. With separate SPA/API registrations its audience is wrong. Disabling audience validation can then admit otherwise-valid tokens meant for other resources accepted by the remaining validation rules; it does not make every issuer or signature valid. Keep audience validation and the endpoint permission/token-type policy enabled.

Good enough to ship

• Send access tokens to APIs; keep ID tokens for the client. • Let MSAL manage acquisition/caching; clients treat access tokens as opaque credentials. • For a BFF keep tokens server-side and protect the session cookie; for an SPA understand MSAL cache/XSS tradeoffs rather than pretending JavaScript has HttpOnly storage.

Expert tier

• Distinguish Conditional Access sign-in/session controls from configurable access-token lifetime policies. • Detect and revoke refresh tokens on signal of compromise. • Use the documented authentication-context/claims-challenge flow for step-up; checking a decoded acr string is not an authentication ceremony.

Standard claims: iss, sub, aud, oid, tid, iat, exp

The seven claims you must know on sight:

  • iss (issuer) — who minted the token. For Entra v2 endpoints this is https://login.microsoftonline.com/<tenantId>/v2.0.
  • sub (subject) — stable per (user, app) pair. Two different apps see different subs for the same user; do not treat it as an eternal cross-tenant person identifier.
  • aud (audience) — who the token is for. For Entra v2 access tokens this is the API client ID; v1 tokens can use an application ID URI or GUID depending on registration. Match the resource token version and metadata.
  • oid (object id) — Entra-specific stable identifier for the user (or service principal). Same across all apps in the tenant; use this for cross-app correlation.
  • tid (tenant id) — the issuing/resource tenant for this token, not necessarily a guest's home tenant. Combine tid and oid for a directory object identity after token validation.
  • iat (issued at) — when the token was minted (unix seconds).
  • exp (expiry) — when it stops being valid (unix seconds).

ASP.NET Core's AddJwtBearer validates iss, aud, exp, signature and nbf by default. Configure Authority and Audience for the actual resource; claim mapping, endpoint policies and clock tolerance are separate deliberate choices. The example chooses a one-minute ClockSkew; it does not disable issuer, signature or lifetime validation.

builder.Services
  .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddJwtBearer(opts =>
  {
    opts.Authority = "https://login.microsoftonline.com/<tid>/v2.0";
    opts.Audience = "11111111-1111-1111-1111-111111111111"; // API client ID, v2 token
    opts.MapInboundClaims = false;
    opts.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(1);
  });

Single-tenant vs multi-tenant; tenant allow-listing

When you register an app, you choose single-tenant (only users from your tenant can sign in), multi-tenant (users from any work or school tenant), or multi-tenant + personal (also Microsoft personal accounts).

A multitenant issuer is tenant-specific. The common/organizations metadata contains a tenant template; a validator must bind its issuer and signing-key issuer to the validated tenant, not merely extract an allowed tid from an arbitrary token. Use Microsoft.Identity.Web's supported validation path, keep issuer validation enabled, then apply customer/tenant authorization separately.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme,
    options => options.MapInboundClaims = false);
 
// For this business API, only enrolled organizational tenants may use the route.
var enrolledTenants = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
};
builder.Services.AddAuthorization(options => options.AddPolicy("EnrolledTenant", policy =>
{
    policy.RequireAuthenticatedUser();
    policy.RequireAssertion(context =>
        context.User.FindFirst("tid") is { } tid && enrolledTenants.Contains(tid.Value));
}));

Configure AzureAd with the correct instance, tenant audience model (organizations for organizational accounts here), API ClientId and resource token version. This snippet is the authentication/tenant part of the policy; each endpoint must also require its delegated scope or app role and authorize the requested resource. An allowed tenant is not permission to read every object in it.

The old custom IssuerValidator returned the supplied issuer after only checking tid. That did not validate the required issuer–tenant–key relationship and is not a safe shortcut. A public SaaS may intentionally allow onboarding from many tenants; the security requirement is a designed enrollment/authorization boundary, not the claim that all open sign-in is inherently a breach. Guests receive tokens in the resource tenant: testing a guest's home tenant is not the same as testing a token issued by a disallowed tenant.

App Service-managed auth (Easy Auth)

Easy Auth runs before application code: a native IIS module on Windows, a separate container on Linux/container apps. Its hosting implementation is not universally a sidecar. Configure it in the Authentication blade: pick an identity provider (Microsoft / Google / Apple / generic OIDC), the redirect URI, and what to do with unauthenticated requests (return 401 or redirect to login). When a request arrives, Easy Auth:

  1. Looks for an existing session cookie or bearer token.
  2. If absent and the policy says "redirect", sends the user to /.auth/login/<provider>.
  3. After login, sets an Easy-Auth-managed cookie and forwards to your app.
  4. Your app receives request headers describing the authenticated principal.

Principal headers can be supplied by Easy Auth after authentication; provider-token headers depend on the enabled token store, provider and granted scopes, and are not guaranteed on every request:

  • X-MS-CLIENT-PRINCIPAL — base64-encoded JSON of the authenticated principal.
  • X-MS-CLIENT-PRINCIPAL-ID — the identifier the provider sets for the caller; interpret it with the provider/tenant contract.
  • X-MS-CLIENT-PRINCIPAL-NAME — display name.
  • X-MS-CLIENT-PRINCIPAL-IDP — which IdP authenticated them.
  • X-MS-TOKEN-AAD-ACCESS-TOKEN — the access token (if you opted in).
  • X-MS-TOKEN-AAD-ID-TOKEN — the ID token.
  • X-MS-TOKEN-AAD-REFRESH-TOKEN — the refresh token (if requested).
// Parse the principal header
var encoded = ctx.Request.Headers["X-MS-CLIENT-PRINCIPAL"].FirstOrDefault();
if (encoded is not null)
{
    var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
    var principal = JsonSerializer.Deserialize<ClientPrincipal>(json);
    // Map the documented auth_typ, claims, name_typ and role_typ fields.
    // This ClientPrincipal DTO and malformed-header handling are application code;
    // base64 decoding alone authenticates nothing.
}

The crucial safety property: trust those headers only when the request actually reached your app through the App Service front door. If your app is reachable directly (a separate origin bypassing the App Service authentication module, or a misconfigured proxy), an attacker can forge the headers and bypass auth. Mitigations:

  • Reject any inbound request with X-MS-CLIENT-* headers if the request did not arrive via the Easy Auth path.
  • Use the documented Microsoft.Identity.Web integration only with its App Service/token-store configuration and the genuine platform boundary; installing a package does not authenticate a forged header on an unrelated host.
  • Do not expose a parallel bypass endpoint; private endpoints alone do not bypass Easy Auth. Test spoofed principal headers on all allowed ingress paths without logging token headers.

Good enough to ship

• Easy Auth can supply authentication; internal-only access still requires tenant/user/app-role authorization and network design. • MSAL in the SPA + AddJwtBearer in the API for user-facing apps. • Verify X-MS-CLIENT-PRINCIPAL rather than trusting it blindly.

Expert tier

• Combine Easy Auth with custom token transformers. • Layer Conditional Access on the Easy Auth app registration. • Pre-claim mappings to push tenant/role data into the principal.

Managed Identity: system vs user-assigned, IMDS, no secrets

Easy Auth answers who is calling the application. Now reverse the direction: when the application calls Key Vault or Storage, what identity does that resource see? A managed identity represents the workload, not the signed-in user. Keep those permissions separate: authenticating a user does not grant the app access to a vault, and granting the app vault access does not authorise every user to retrieve its secrets.

A managed identity is an Entra service principal whose credentials are managed by Azure. Azure manages the underlying credential. On App Service, the SDK uses the internally exposed IDENTITY_ENDPOINT and identity header; VM IMDS at 169.254.169.254 is a different hosting path. Use the SDK instead of printing those hidden endpoint/header values. The app still receives a bearer access token.

Two flavours:

  • System-assigned — bound to the lifecycle of the resource. Created when you enable it on the App Service; deleted when the App Service is deleted. One identity per resource.
  • User-assigned — created independently in a resource group and attached to one or many resources. Survives resource deletion. Useful when you want one identity shared across slots, regions or replicas.
// Development example; select the intended user-assigned identity explicitly when used
var credential = new DefaultAzureCredential();
var kv = new SecretClient(new Uri("https://my-vault.vault.azure.net"), credential);
var secret = await kv.GetSecretAsync("db-password");

Managed identity removes a developer-managed credential for this authentication hop. It does not remove fetched secrets/access tokens from process memory or prevent misuse by compromised code. A shared user-assigned identity shares blast radius; prefer a narrowly scoped identity per permission boundary.

Federated Identity Credentials for workload identity

For workloads that cannot use Azure MI — most importantly, GitHub Actions, Azure DevOps Pipelines, and Kubernetes pods elsewhere — Entra supports Federated Identity Credentials. You configure an app registration to trust tokens issued by an external identity provider (GitHub's OIDC issuer, your AKS cluster's OIDC issuer). The external workload presents its OIDC token; Entra exchanges it for an Azure access token; no long-lived client secret is stored; short-lived bearer assertions/tokens still cross their intended trusted endpoints.

# Step fragment: enclosing job needs permissions: { id-token: write }
- uses: azure/login@v2
  with:
    client-id: ${{ vars.AZURE_CLIENT_ID }}
    tenant-id: ${{ vars.AZURE_TENANT_ID }}
    subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

The federated credential is configured on the Entra app registration with a subject like repo:myorg/myrepo:ref:refs/heads/main — Entra validates the signed external assertion against the configured issuer, subject and audience, not just the subject. Branch-bound and environment-bound jobs have different subject formats; use the exact one your job emits. id-token: write permits requesting the assertion, not writing Azure resources. The Azure principal still needs narrowly scoped role grants. No long-lived client secret is stored for this hop, but tokens, trust rules, signing-key rollover and audit retention still require security ownership.

Azure SDK credential chain: DefaultAzureCredential vs explicit

DefaultAzureCredential is a chain. It tries credentials in order, returning the first that produces a token:

The Azure.Identity 1.13.2 factory builds the optional chain in this order: Environment, WorkloadIdentity, ManagedIdentity, SharedTokenCache, VisualStudio, VisualStudioCode, AzureCli, AzurePowerShell, AzureDeveloperCli and InteractiveBrowser. Each step depends on its Exclude option and platform support; SharedTokenCache, VisualStudioCode and InteractiveBrowser are excluded by default in these pinned options. Do not substitute a newer release’s chain into this pinned example. Read the tagged source and options when upgrading; not every credential is enabled or usable on every machine.

A deployed credential that can attempt authentication but fails can terminate the chain; developer-credential continuation follows the SDK's versioned policy. “Try every credential until one works” is therefore not a complete failure model.

This is convenient — it "just works" locally (Azure CLI) and in production (managed identity). It also hides bugs: a usable environment credential configuration (AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET, for example) can take precedence over managed identity. In production, prefer being explicit:

// In production code paths, prefer explicit credentials:
var credential = builder.Environment.IsProduction()
    ? (TokenCredential)new ManagedIdentityCredential()
    : new ChainedTokenCredential(new AzureCliCredential(), new VisualStudioCredential());

This makes the credential source visible at code review time, avoids accidental fallbacks, and produces clearer error messages when auth fails (a known credential path rather than a variable enabled chain).

Good enough to ship

• DefaultAzureCredential is fine for samples and dev. • Record the credential package version, enabled chain and expected principal; do not memorize an obsolete universal order. • ManagedIdentityCredential explicitly in production code.

Expert tier

• Configure DefaultAzureCredentialOptions.ExcludeXxx to narrow the chain. • Pool TokenCredential instances; they cache aggressively. • Diagnose chained failures via AzureEventSourceListener.

Granting an identity to a downstream resource

Having a managed identity is only half the story; the identity has to be authorised on the downstream resource. Many resources use data-plane Azure RBAC, but SQL database users/roles and ADX database permissions have their own authorization setup. A generic management-plane role is not universal data access.

# Grant the App Service's MI Key Vault Secrets User on a specific vault
PRINCIPAL_ID=$(az webapp identity show -g rg-prod -n api-prod --query principalId -o tsv)
az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Key Vault Secrets User" \
  --scope $(az keyvault show -n kv-prod --query id -o tsv)
# Grant the same MI Storage Blob Data Reader on a storage account
az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Reader" \
  --scope $(az storage account show -n stprod -g rg-prod --query id -o tsv)

These are provisioning sketches, not commands to run against production during this review. The vault must use the Azure RBAC permission model for Key Vault Secrets User to grant data access; assignment authority, the correct principal and allowed network path are separate prerequisites. The storage example grants at account scope; use the container resource ID when only one container is required.

A few patterns to internalise:

  • Least privilege: grant the narrow role on the narrow scope. Reader on a subscription is sloppy; Storage Blob Data Reader on one container is correct.
  • Role assignment delay: assignments propagate asynchronously; resource caches and managed-identity group membership can take hours (around 24 hours in documented cases). A 403 can also mean wrong principal, scope, permission model or network path. Diagnose before waiting or broadening access.
  • Data-plane vs management-plane roles: most Azure resources have separate role sets for "manage the resource" and "use the resource's data". You almost always want the data-plane role (e.g. Key Vault Secrets User, not Key Vault Contributor).

Worked examples

Example 1 — SPA + API with MSAL.js and AddJwtBearer

import { PublicClientApplication, InteractionRequiredAuthError } from "@azure/msal-browser";
 
const client = new PublicClientApplication({
  auth: {
    clientId: "00000000-0000-0000-0000-000000000000", // SPA registration
    authority: "https://login.microsoftonline.com/<tid>",
    redirectUri: window.location.origin, // exact registered SPA redirect
  },
  cache: { cacheLocation: "sessionStorage" },
});
await client.initialize();
const scopes = ["api://11111111-1111-1111-1111-111111111111/access_as_user"];
 
// Call from an explicit user action. Multiple accounts require an account picker.
async function callApi() {
  const accounts = client.getAllAccounts();
  if (accounts.length > 1) throw new Error("Choose an account before calling the API");
  const account = accounts[0] ?? (await client.loginPopup({ scopes })).account;
  if (!account) throw new Error("No signed-in account");
  let result;
  try { result = await client.acquireTokenSilent({ scopes, account }); }
  catch (error) {
    if (!(error instanceof InteractionRequiredAuthError)) throw error;
    result = await client.acquireTokenPopup({ scopes, account });
  }
  const response = await fetch("/api/whoami", {
    headers: { Authorization: `Bearer ${result.accessToken}` },
  });
  if (!response.ok) throw new Error(`API status ${response.status}`);
  return response.json(); // validate the application response schema at its consumer
}

Do not automatically launch repeated popups for arbitrary errors or background requests. Handle consent/claims challenges through the supported MSAL flow. Session storage remains JavaScript-readable: CSP, dependency hygiene and XSS prevention still matter.

// API: Program.cs
builder.Services
  .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
 
builder.Services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme,
    options => options.MapInboundClaims = false);
builder.Services.AddAuthorization(opts =>
  opts.AddPolicy("RequireApiRead", p => p.RequireAssertion(ctx => ctx.User.Identity?.IsAuthenticated == true && (ctx.User.FindFirst("scp")?.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("access_as_user") ?? false))));
 
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
 
app.MapGet("/api/whoami", (ClaimsPrincipal user) => new {
    oid = user.FindFirstValue("oid"),
    name = user.Identity?.Name,
    scopes = user.FindFirstValue("scp")
}).RequireAuthorization("RequireApiRead");
app.Run();
  • MSAL handles code + PKCE; silent acquisition can require interactive consent/reauthentication, handled explicitly above.
  • AddMicrosoftIdentityWebApi is AddJwtBearer plus sensible defaults for Entra (issuer, audience, scope policy helpers).
  • The API endpoint reads claims directly from ClaimsPrincipal; no manual token parsing.

Example 2 — On-behalf-of flow to Microsoft Graph

This is a separate integration sketch. Configure the API as a confidential client with its own protected certificate/client assertion, consent for delegated Graph User.Read, the inbound access_as_user policy and AddHttpClient. A managed identity token by itself does not impersonate a user or supply OBO client authentication. A certificate public key is registered with Entra; its private key remains protected, with overlapping rotation and expiry monitoring. Never put that private key in the browser.

// API needs to call Graph /me as the original user
builder.Services
  .AddMicrosoftIdentityWebApiAuthentication(builder.Configuration)
  .EnableTokenAcquisitionToCallDownstreamApi(["https://graph.microsoft.com/User.Read"])
  .AddInMemoryTokenCaches();
 
app.MapGet("/api/profile", async (
    ClaimsPrincipal user,
    ITokenAcquisition tokens,
    IHttpClientFactory http) =>
{
    var accessToken = await tokens.GetAccessTokenForUserAsync(
        new[] { "https://graph.microsoft.com/User.Read" });
    using var client = http.CreateClient();
    client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
    var profile = await client.GetFromJsonAsync<JsonElement>("https://graph.microsoft.com/v1.0/me");
    return profile;
}).RequireAuthorization("RequireApiRead");
  • EnableTokenAcquisitionToCallDownstreamApi plugs in OBO support.
  • AddInMemoryTokenCaches is fine for single-instance dev; multi-instance production normally needs a supported distributed token cache with access control, encryption/data protection, eviction and availability design. It contains sensitive tokens, not harmless application results.
  • The downstream token represents delegated user access and the calling app. Audit attribution can include both; do not promise a user-only audit record. Pass the incoming API token to the OBO library, not directly to Graph, and propagate interaction/consent challenges safely.

Example 3 — Managed Identity + Key Vault, no client secret

using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Azure;
 
builder.Services.AddAzureClients(b =>
{
    b.UseCredential(builder.Environment.IsProduction()
        ? new ManagedIdentityCredential()
        : new DefaultAzureCredential());
    b.AddSecretClient(new Uri("https://kv-prod.vault.azure.net"));
});
 
// Consume secrets inside a service, never expose secret values or metadata through a diagnostic endpoint.
// Read a required secret during a scoped dependency operation; use a boolean readiness check without secret content.
  • Production code path uses ManagedIdentityCredential explicitly.
  • Dev/local uses DefaultAzureCredential so az login works.
  • No developer-managed credential is needed for this Key Vault authentication path; the fetched secret still exists in process memory.

Example 4 — Federated identity for GitHub Actions

# Create the federated credential on the app registration
az ad app federated-credential create \
  --id $AAD_APP_ID \
  --parameters '{
    "name": "github-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:myorg/myrepo:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'
name: Deploy
on: { push: { branches: [main] } }
permissions:
  id-token: write   # required for OIDC
  contents: read
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    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 group list --query "[].name" -o tsv
  • Federated credential matches OIDC token issued by GitHub for main-branch runs.
  • No long-lived client secret in GitHub Secrets for this hop; review trust conditions, workflow/environment protections, token handling and Azure roles. The sample lists groups; it is not a deployment implementation.

Hands-on exercises

  1. SPA + API + whoami. Register an SPA app reg and an API app reg. Configure PKCE redirect, expose access_as_user scope on the API, grant the SPA permission to it, using the API app ID URI from Example 1. Build the snippets from Example 1 and confirm a 200 from /api/whoami returning the authenticated oid.

    • You are done when you can describe what happens at every step of the auth code + PKCE round trip.
  2. OBO to Graph. Extend the API from exercise 1 to call Graph /me. Render the user's display name in the SPA.

    • You are done when the Graph token has the Graph audience and authorized delegated user scope; inspect permitted audit metadata for both user and app attribution.
  3. Managed identity + Key Vault. Enable system-assigned MI on an App Service; grant it Key Vault Secrets User on a vault. Use Example 3's code to fetch a secret.

    • You are done when no developer-managed client credential is required; the retrieved secret still exists in process memory and must not be returned or logged.
  4. Multi-tenant allow-list. Configure your app as multi-tenant. Apply the tenant authorization policy after library token validation. Test a token issued by an unenrolled tenant as well as a guest token issued by the enrolled resource tenant; those are different cases.

    • You are done when valid tokens from unenrolled tenants are denied this business route after authentication, while an enrolled resource-tenant guest is handled by the intended user/resource policy.
  5. Easy Auth header check. Stand up a second App Service with Easy Auth enabled. Inspect a redacted synthetic principal in a local fixture and verify the configured hosting trust boundary. Do not log principal/token headers or real user identifiers indiscriminately.

    • You are done when you can decode documented principal fields, explain mapped versus original claim names and token-header availability, and show the ingress cannot accept a forged principal.
  6. Federated credential. Wire a GitHub Actions deploy that authenticates to Azure with federated credentials (no client secret). Use the read-only az account show --query "{tenant:tenantId,subscription:id}" command to inspect the selected context; Azure CLI has no generic whoami command.

    • You are done when the intended job authenticates without a stored client secret, a wrong subject is rejected, and the resulting principal can perform only its authorized Azure operations.

Self-check questions

  1. What's the difference between an ID token and an access token? Where does each belong?
  2. Why can a SPA not keep a client secret, and which code-interception risk does PKCE reduce?
  3. Explain the OBO flow in four steps.
  4. Which standard claims must be validated, and how would you diagnose an audience mismatch without weakening validation?
  5. What's the difference between a delegated scope (scp) and an app role (roles)?
  6. How does a managed identity actually fetch a token? Where does the credential live?
  7. What does DefaultAzureCredential try, in what order, and when would you replace it with ManagedIdentityCredential?
  8. Why must multitenant sign-in be separated from enrolled-tenant and resource authorization?
  9. Explain the relationship between an app registration, a service principal, and an enterprise application.
  10. What headers does Easy Auth inject and why should you not trust them blindly?
  11. How does a federated identity credential authenticate a GitHub Actions runner without a stored secret?
  12. Why is Storage Blob Data Reader correct and Reader wrong when you only need to read blob data?

Worked assessment answers and negative cases

  1. An ID token authenticates a session to its OIDC client; an access token targets a resource API. Clients treat access tokens as opaque. The API validates its own audience and permissions, not a Graph token or an ID token meant for the SPA.
  2. A public SPA ships its code/configuration to the browser, so it cannot keep a client secret. PKCE binds code redemption to the verifier from the initiating client; it does not replace confidential-client authentication or defend an already compromised browser.
  3. OBO has four steps: SPA acquires your API's delegated access token; API validates and authorizes it; the confidential API authenticates itself and exchanges that assertion for a downstream token; Graph validates the downstream token's audience and delegated permissions. Never forward the incoming API token directly to Graph.
  4. Validate signature, issuer/tenant/signing-key relationship, audience, expiry and not-before through the library and trusted metadata. An audience mismatch is a useful diagnostic, not evidence for disabling validation or a measured ranking of production bugs.
  5. scp is a space-delimited delegated permission list. roles can represent application permissions or user-assigned app roles; its presence alone does not distinguish app-only from delegated access. Require the intended caller/token type plus scope/role and object authorization.
  6. On App Service the SDK uses the internally exposed managed-identity endpoint and platform header; Azure manages the underlying credential. VM IMDS is a different hosting path. Your process still receives a bearer token and may retrieve actual secrets: neither should be logged.
  7. The pinned 1.13.2 optional factory order is Environment, WorkloadIdentity, ManagedIdentity, SharedTokenCache, VisualStudio, VisualStudioCode, AzureCli, AzurePowerShell, AzureDeveloperCli, InteractiveBrowser. Exclude options/platform support control participation; SharedTokenCache, VisualStudioCode and InteractiveBrowser are off by default in the pinned options. A deployed authentication failure can stop the chain. An explicit production ManagedIdentityCredential narrows the source, with user-assigned identity selection when applicable.
  8. Open multitenant sign-in can be intentional. Library issuer validation establishes which tenant issued a valid token; enrolled-tenant and resource policies decide which business operations it may perform. A guest's home tenant is not necessarily the token's resource tenant.
  9. The application object defines an app; each consenting tenant has its own application-type service principal; Enterprise applications manages that local instance. Managed-identity service principals do not require an application object.
  10. Principal ID/name/IDP and the base64 principal describe the authenticated caller behind Easy Auth. Provider token headers are conditional and sensitive. The platform blocks externally supplied principal headers on its protected ingress; a separate bypass host must not trust them. Base64 decoding proves no identity by itself.
  11. GitHub issues a short-lived signed assertion when the job has id-token: write; Entra validates issuer, subject and audience against federation trust and issues a token for the principal. Azure roles separately authorize its operations. A branch subject does not match an environment subject.
  12. Storage Blob Data Reader grants blob data reads at the chosen scope. Reader gives management-plane visibility, not blob contents. Scope to a container when account-wide data access is unnecessary.

Exercise 1 solution: Use separate SPA/API registrations, an exact SPA redirect and the API's exposed access_as_user scope. Initialize MSAL before use, acquire an API access token, and attach the route's scope policy. A missing token should challenge; a valid token without the scope should be forbidden; a wrong-audience token must fail validation. A 200 alone does not prove object authorization.

Exercise 2 solution: Register the API's protected confidential-client credential, obtain delegated Graph consent, configure token acquisition and a suitable cache, and call Graph /me with the separately acquired Graph token. Treat consent/claims challenges as a supported interactive flow. Audit user and app attribution without logging tokens; do not substitute a workload-only token.

Exercise 3 solution: Enable the intended managed identity, grant Key Vault Secrets User on an RBAC-mode vault and allow its network route. Fetch inside an authorized operation and expose only nonsecret readiness. For a 403, check principal, scope, vault permission model, network and propagation; do not grant Owner as a diagnostic shortcut.

Exercise 4 solution: Keep supported multitenant issuer validation, then apply EnrolledTenant and the route's permission/resource policy together. Test a token issued by an unenrolled tenant separately from a guest token issued by the enrolled tenant. The former fails enrollment; the latter still needs user/resource authorization.

Exercise 5 solution: Use synthetic base64 JSON with auth_typ, claims, name_typ, role_typ to test decoding and malformed input. Independently test the platform trust boundary on every permitted ingress. Do not dump principal/token headers into logs; a decoder test is not an Easy Auth security test.

Exercise 6 solution: Give only the intended job OIDC permission, match its issuer/subject/audience and grant a narrow Azure role. Inspect selected account context with the read-only command, then verify a wrong subject and an unauthorized operation are denied. Listing account context is not proof of successful deployment. Resource example: tenant A's valid scope token requests tenant B's dashboard ID. Derive tenant from validated claims, load the resource within that tenant scope and authorize ownership/role before reading a cache or database result. Return a consistent denial/not-found policy without leaking existence. A cache keyed only by dashboard ID must not bypass this check.

For the practical exercises, test missing scope, wrong audience, unenrolled issuer, guest/resource-tenant semantics, forged principal headers and wrong GitHub federation subject using synthetic fixtures or an explicitly authorized test tenant. A passing login screen is not an authorization test.

High-signal resources

Official docs

Books or courses

  • OAuth 2 in Action — Justin Richer and Antonio Sanso; an optional book on OAuth design. Confirm the edition against the publisher listing.
  • For an optional video course, inspect the current Pluralsight catalog and syllabus; the earlier unlinked “Microsoft identity platform deep dive” title and four-hour duration are not verified here.

Practitioner posts

  • Cloudidentity archive — optional historical reading; check each page's author/date and compare older protocol or library advice with current official contracts.
  • jwt.io — useful for learning with synthetic tokens. Do not paste production bearer tokens into third-party tools; inspect only sanitized synthetic claims locally.
  • Microsoft identity developer blog — browse posts by their actual author/date; this index does not substantiate the earlier attribution to Mike Pizzo or a specific token-validation article.

Weekly milestones

  1. Day 1. Read the platform overview + the auth code + PKCE write-up. Do exercise 1. Self-check questions 1–3.
  2. Day 2. Token claims + multi-tenant allow-list (exercise 4). Self-check questions 4–5 + 8.
  3. Day 3. Easy Auth (exercise 5). Self-check questions 9–10.
  4. Day 4-5. Managed identity + OBO (exercises 2 + 3). Self-check questions 6 + 11.
  5. Day 6-7. Federated credential for CI (exercise 6). Self-check questions 7 + 12.

How it shows up in the capstone

The capstone design uses MSAL.js with PKCE in the SPA; the API uses Microsoft.Identity.Web with AddMicrosoftIdentityWebApiAuthentication. The intended API App Service uses a system-assigned managed identity with Key Vault Secrets User on the vault (chapter 13), a contained Microsoft Entra database user with the minimal SQL permissions (not an invented Azure RBAC “Database” role), and Storage Blob Data Reader on the analytics storage account. No long-lived client secret is used for these MI/federation hops. OBO separately needs a protected confidential-client credential/assertion and token cache.

App Roles Admin and Viewer gate dashboard editing vs viewing through [Authorize(Policy = "Admin")] and policy-based authorisation. CI deploys via federated identity from GitHub Actions; the workflow file uses no secrets, only public variables. Easy Auth can authenticate staging users; explicit tenant/user authorization and network restrictions make that environment internal-only.

Previous chapter → Ch 13 — Azure App Service Next chapter → Ch 15 — Observability

Corrected contracts and failure analysis

An ID token is for the client; an access token is for its resource audience. Validate issuer, signature, time and the API's expected audience through a supported library, then enforce delegated scopes or application roles. Entra scp is space-delimited. Neither possession of an oid nor successful login proves resource ownership; tenant and object IDs together form a useful multi-tenant identity key.

Managed identity removes a developer-managed credential for supported workloads, not authorization checks or all secrets from the process. It still obtains bearer tokens that must not be logged. CI federation trusts a specific issuer/subject/audience and then requires Azure role grants; adding a federated credential alone grants no Key Vault access. An environment-bound GitHub job has a different subject from a branch-bound one. Prefer an explicit production credential over an unexpectedly broad development credential chain.

Boundary exercise with solution

A signed Graph access token arrives at your own API with the right user. Can the API accept it?

Solution and reasoning

No. Its audience is Graph, not your API. Accepting it is a confused-deputy failure. The client requests your API scope; the API uses on-behalf-of to acquire a separate downstream token if authorized, rather than forwarding an arbitrary token.

Source-backed review notes

  • Access tokens in the Microsoft identity platform - Microsoft identity platform | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “An access token request involves two parties: the client, who requests the token, and the resource (Web API) that accepts the token. The resource that the token is intended for (its audience) is defined in the aud claim in a token. Clients use the token but shouldn't understand or attempt to parse it. Resources accept the token.”
  • Managed identities for Azure resources - Managed identities for Azure resources | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “A common challenge for developers is the management of secrets, credentials, certificates, and keys used to secure communication between services. Manual handling of secrets and certificates are a known source of security issues and outages. Managed identities eliminate the need for developers to manage these credentials. Applications can use managed identities to obtain Microsoft Entra tokens without having to manage any credentials.”
  • Resource-based authorization in ASP.NET Core | Microsoft Learn — accessed 2026-10-07. Exact supporting passage: “Resource-based authorization requires special attention in ASP.NET Core apps. Attribute evaluation occurs before data binding and before execution of any method that loads a resource. Declarative authorization with an [Authorize] attribute doesn't suffice for resource-based authorization. Instead, the app must invoke a custom authorization method—an approach known as imperative authorization.”

Versioned source contracts

Official passages were checked on 7 October 2026; immutable tags are used where available, while service docs remain dated snapshots. The saved evidence records include exact passages, URL, source date/revision and snapshot hash. These references support the stated contracts, not an unperformed service deployment.

  • identity — Client treats access token as opaque; API validates its own audience.
  • auth-multitenant — Issuer must match the tenant/metadata contract, not an arbitrary issuer string after tid allowlisting.
  • auth-obo — OBO is delegated downstream access and requires a confidential middle tier.
  • auth-chain-factory — Azure.Identity 1.13.2 factory includes an optional shared-token-cache step and version-specific chain.
  • auth-caching — MSAL browser cache is not an HttpOnly cookie store controlled by JavaScript.
  • auth-easy — Easy Auth hosting differs for Windows and Linux; not universally a sidecar.
  • auth-mi — App Service managed identity uses its platform endpoint; VM IMDS is a different host path.
  • auth-rbac — Managed-identity membership caching can cause long permission propagation delays.
  • authz — Login/scopes are insufficient for object ownership; resource authorization is separate.

Additional contract references

Checked 9 October 2026 as dated official snapshots or the explicit source tags below; these support the stated mechanisms, not a live-service reproduction.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.