Search Tech Journey

Find topics, journeys and posts

back to blog
system designintermediate 36m2026-06-10

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.

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 decode a JWT at a glance and tell someone whether the aud claim matches the API it is being sent to.

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. Every user, group and app principal lives in exactly one home 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).

A service principal is the runtime identity of an app registration inside a tenant. When you create an app registration, 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. The Entra portal shows the same service principal twice: under "App registrations" (developer view) and under "Enterprise applications" (admin view of who has consented, who can sign in, conditional access policies, etc.). They are not separate objects.

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 — used by every SPA and every native app 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. PKCE replaces the old client-secret-in-the-browser hack and is mandatory for SPAs.
  • 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 sub claim of the resulting token is the app's service principal, not a user.
  • 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

The legacy grants (implicit, resource owner password, device code where avoidable) exist but you should not reach for them in new applications. ROPC is a security disaster; implicit is deprecated in favour of PKCE.

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. Contains user claims (sub, oid, preferred_username, name, email). 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). Contains scopes (scp) for delegated calls or roles (roles) for app-only calls. Short-lived (1 hour by default in Entra).
  • Refresh token — long-lived credential exchanged at the token endpoint for a new access token. Lives in the browser's secure cookie storage (or a backend-for-frontend) and never travels to the API. Revocable.
// Access token payload (decoded)
{
  "aud": "api://my-api",
  "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": "User.Read api.read",          // delegated scopes
  "roles": ["Admin"]                    // app roles
}

The single most common bug in this space is sending an ID token to an API. The API rejects it (aud mismatch), the developer disables audience validation (catastrophic), and now the API accepts tokens issued for any other app in the world.

Good enough to ship
  • • Send access tokens to APIs; keep ID tokens for the client.
  • • Cache access tokens by their exp.
  • • Store refresh tokens in HttpOnly; Secure cookies, never in localStorage.
Expert tier
  • • Configure token lifetimes via Conditional Access for the use case.
  • • Detect and revoke refresh tokens on signal of compromise.
  • • Implement step-up auth via acr claim checks.

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; one app sees the same sub for the same user forever.
  • aud (audience) — who the token is for. Validate against your API's identifier URI (or clientId for v2 tokens issued to the same app).
  • 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) — which Entra tenant the user belongs to. Required for multi-tenant 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. The only knob you might tweak is ClockSkew (5 minutes by default; reduce to 1 minute for tighter checks).

builder.Services
  .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddJwtBearer(opts =>
  {
    opts.Authority = "https://login.microsoftonline.com/<tid>/v2.0";
    opts.Audience = "api://my-api";
    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).

For multi-tenant apps you have to do two things differently:

  1. The token's iss will be tenant-specific (.../v2.0) — you cannot validate against a single fixed issuer. Use the common metadata endpoint and let Microsoft.IdentityModel validate via the tid claim plus aud.
  2. You almost certainly want to allow-list which tenants may sign in. Without an allow-list, any Entra tenant in the world can consent to your app and use it.
opts.Authority = "https://login.microsoftonline.com/common/v2.0";
opts.TokenValidationParameters.ValidateIssuer = false; // we validate manually:
opts.TokenValidationParameters.IssuerValidator = (issuer, token, parameters) =>
{
    var jwt = (JsonWebToken)token;
    var tid = jwt.GetClaim("tid").Value;
    if (!_allowedTenants.Contains(tid))
        throw new SecurityTokenInvalidIssuerException("tenant not allow-listed");
    return issuer;
};

App Service-managed auth (Easy Auth)

Easy Auth runs as a sidecar on every App Service worker, in front of your code. 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.

The headers Easy Auth injects:

  • X-MS-CLIENT-PRINCIPAL — base64-encoded JSON of the authenticated principal.
  • X-MS-CLIENT-PRINCIPAL-ID — the user's stable id.
  • 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);
    // principal.UserId, principal.Claims, etc.
}

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 (private endpoint without Easy Auth termination, or a misconfigured exposure), 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 Microsoft.Identity.Web's integration which trusts only the Easy-Auth-validated principal.
  • Or, do not run anything else on the same port.
Good enough to ship
  • • Easy Auth for internal admin tools — no code required.
  • • 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

A managed identity is an Entra service principal whose credentials are managed by Azure. Your app never sees the secret; the Azure SDK fetches tokens via the Instance Metadata Service (http://169.254.169.254/metadata/identity/oauth2/token), which is only reachable from inside the resource.

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.
// In code, you do not care which flavour — let DefaultAzureCredential figure it out
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 eliminates an entire class of incidents: leaked secrets in git history, secrets baked into container images, secrets emailed to teammates, secrets discovered in heap dumps. If a resource can use a managed identity, it should.

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 secret ever leaves the workload.

# GitHub Actions — no secret needed
- uses: azure/login@v2
  with:
    client-id: ${{ secrets.AZURE_CLIENT_ID }}     # public; not a secret
    tenant-id: ${{ secrets.AZURE_TENANT_ID }}     # public; not a secret
    subscription-id: ${{ secrets.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 issues a token only when the OIDC subject matches. The result: no client secret in GitHub, automatic rotation by virtue of nothing to rotate, audit trail per (repo, branch, environment).

Azure SDK credential chain: DefaultAzureCredential vs explicit

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

  1. EnvironmentCredentialAZURE_CLIENT_ID/SECRET/TENANT_ID env vars.
  2. WorkloadIdentityCredential — federated identity in AKS.
  3. ManagedIdentityCredential — system or user-assigned MI.
  4. SharedTokenCacheCredential — Visual Studio's token cache.
  5. VisualStudioCredential — Visual Studio interactive.
  6. AzureCliCredentialaz login session.
  7. AzurePowerShellCredentialAz.Accounts session.
  8. InteractiveBrowserCredential (disabled by default).

This is convenient — it "just works" locally (Azure CLI) and in production (managed identity). It also hides bugs: a stray AZURE_CLIENT_SECRET in your environment will silently 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 single attempt with a single reason vs eight attempts with eight reasons).

Good enough to ship
  • DefaultAzureCredential is fine for samples and dev.
  • • Know the probe order — six lines, written down somewhere.
  • 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. The pattern is uniform across Azure: assign an Azure RBAC role on the resource scope to the identity.

# 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)

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 within a minute but can take up to 15 minutes globally. If your app gets a 403 immediately after assignment, that's why.
  • 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

<!-- SPA: index.html includes MSAL.js -->
<script src="https://alcdn.msauth.net/browser/3.x/js/msal-browser.min.js"></script>
<script type="module">
  const msal = new msal.PublicClientApplication({
    auth: {
      clientId: "00000000-0000-0000-0000-000000000000",
      authority: "https://login.microsoftonline.com/<tid>",
      redirectUri: window.location.origin
    },
    cache: { cacheLocation: "sessionStorage" }
  });

  async function callApi() {
    const account = msal.getAllAccounts()[0] ?? (await msal.loginPopup({ scopes: ["api://my-api/access_as_user"] })).account;
    const result = await msal.acquireTokenSilent({ scopes: ["api://my-api/access_as_user"], account });
    const r = await fetch("/api/whoami", { headers: { Authorization: `Bearer ${result.accessToken}` } });
    console.log(await r.json());
  }
</script>
// API: Program.cs
builder.Services
  .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));

builder.Services.AddAuthorization(opts =>
  opts.AddPolicy("RequireApiRead", p => p.RequireClaim("scp", "api.read")));

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")
});
app.Run();
  • MSAL handles the auth code + PKCE round trip; you only call acquireTokenSilent.
  • 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

// 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;
});
  • EnableTokenAcquisitionToCallDownstreamApi plugs in OBO support.
  • AddInMemoryTokenCaches is fine for single-instance dev; production needs a distributed cache (AddDistributedTokenCaches with Redis).
  • The downstream Graph call sees the original user as the subject; the audit log shows the user, not the API.

Example 3 — Managed Identity + Key Vault, no secrets anywhere

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"));
});

app.MapGet("/api/db-password-len", async (SecretClient kv) =>
{
    var s = await kv.GetSecretAsync("db-password");
    return new { length = s.Value.Value.Length };
});
  • Production code path uses ManagedIdentityCredential explicitly.
  • Dev/local uses DefaultAzureCredential so az login works.
  • No secret in code, in config, or in source control. Ever.

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 client secret in GitHub Secrets; rotation by virtue of nothing to rotate.

Hands-on exercises

  1. SPA + API + whoami. Register an SPA app reg and an API app reg. Configure PKCE redirect, expose api.read scope on the API, grant the SPA permission to it. 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 audit log on Graph attributes the call to the user, not to the API.
  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 secret appears in your code, config, or environment.
  4. Multi-tenant allow-list. Configure your app as multi-tenant. Implement the tid allow-list shown above with a list of one tenant (yours). Try signing in from a guest user in a different tenant — confirm it is rejected.

    • You are done when only allow-listed tenants can authenticate.
  5. Easy Auth header check. Stand up a second App Service with Easy Auth enabled. Use a small middleware to log every X-MS-CLIENT-* header. Confirm the values match the user you signed in as.

    • You are done when you can map every header back to a token claim.
  6. Federated credential. Wire a GitHub Actions deploy that authenticates to Azure with federated credentials (no client secret). Run a whoami Azure CLI command from the runner.

    • You are done when the action succeeds with zero secrets stored in GitHub.

Self-check questions

  1. What's the difference between an ID token and an access token? Where does each belong?
  2. Why does PKCE replace the client secret for SPAs?
  3. Explain the OBO flow in four steps.
  4. Which standard claims must be validated, and which one is the canonical source of the most production bugs?
  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 is a multi-tenant app without a tid allow-list a security problem?
  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?

High-signal resources

Official docs

Books or courses

  • OAuth 2 in Action — Justin Richer & Antonio Sanso. The book.
  • Pluralsight Microsoft identity platform deep dive — solid four-hour walkthrough.

Practitioner posts

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's SPA uses MSAL.js with PKCE; the API uses Microsoft.Identity.Web with AddMicrosoftIdentityWebApiAuthentication. The API's App Service has a system-assigned managed identity that has Key Vault Secrets User on the vault (chapter 13), Database role on the SQL server, and Storage Blob Data Reader on the analytics storage account. No client secrets exist anywhere in the codebase or configuration.

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 optionally fronts the staging slot as a quick "internal-only" gate during pre-prod testing.

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