🔐 Azure Security

Security & Identity
Complete Guide

From zero to production-hardened — Managed Identity, Key Vault secrets management, OAuth 2.0 grant flows, JWT structure & server-side validation, Microsoft Entra ID app registrations, scopes, roles, B2C, Continuous Access Evaluation, and every zero-trust pattern you need to build secure Azure applications.

Beginner → Architecture24 SectionsZero TrustOAuth 2.0 / OIDC.NET 8 Examples

01Security & Identity Overview

Modern Azure applications are built on a layered security model where identity IS the new perimeter. Instead of relying on network firewalls alone, every service call is authenticated, every secret is managed centrally, and access is granted based on verified identity — never hard-coded credentials. The five pillars covered in this guide work together:

🤖
Managed Identity
Azure-managed credentials for services. Zero secrets in code — the platform handles token issuance and rotation automatically.
🔑
Key Vault
Centralized secrets store. Connection strings, API keys, certificates — retrieved at runtime by identity, never baked into config.
🔀
OAuth 2.0
The authorization framework underpinning all modern identity flows — delegated user access, machine-to-machine, SPAs, mobile apps.
🎫
JWT
The token format carrying identity claims. Understanding its structure, signing, and server-side validation is fundamental to API security.
🏢
Microsoft Entra ID
Azure's cloud identity platform. App registrations, scopes, roles, Conditional Access, B2C external identity — the identity control plane.

How They Fit Together

text
User / Service
    │
    ▼  authenticates with
Microsoft Entra ID  ──────────────────────────────────────────────────────────┐
    │  issues                                                                  │
    ▼                                                                          │
JWT Access Token  (OAuth 2.0 Bearer)                                          │
    │                                                                          │
    ▼  sent to                                                                 │
Your API / Microservice                                                        │
    │  validates JWT (signature, issuer, audience, expiry, claims)             │
    │                                                                          │
    │  needs a secret? calls                                                   │
    ▼                                                                          │
Azure Key Vault  ◄── authenticated via Managed Identity (no password needed) ◄┘
    │  returns secret value
    ▼
Your Business Logic

02Managed Identity

Managed Identity gives an Azure resource (VM, App Service, Function, AKS pod, etc.) an automatically managed Entra ID identity. The Azure platform creates the service principal, rotates its credentials, and serves short-lived tokens via the Instance Metadata Service (IMDS) endpoint. Your code callsDefaultAzureCredential — zero secrets anywhere.

FeatureSystem-AssignedUser-Assigned
LifecycleTied to the Azure resource — deleted with itIndependent — survives resource deletion
Shared acrossOne resource onlyMultiple resources can share one identity
CreationEnabled on the resource itselfCreated separately, then assigned
Use caseSingle-service identity, simplest setupShared identity, pre-provisioned credentials
Role assignmentsPer resourceAssign once, attach to many resources
Entra Object IDAuto-generatedSpecified at creation

02aSystem-Assigned Identity

A system-assigned managed identity is created directly on an Azure resource and shares its lifecycle — when the resource is deleted, the identity is automatically cleaned up. This is the simplest and most common pattern: enable it with one CLI command or a portal toggle, then grant RBAC roles to the identity's principal ID. Use system-assigned identities when a single resource needs its own isolated credentials, such as an App Service accessing Key Vault or a Function App sending messages to Service Bus. The identity is unique to that resource and cannot be shared, making it ideal for clear, auditable access boundaries.

Enable & Assign via CLI

bash
# ── App Service ──────────────────────────────────────────────────────
az webapp identity assign \
  --resource-group myRG \
  --name myWebApp

# Capture the principal ID for role assignments
PRINCIPAL_ID=$(az webapp identity show \
  --resource-group myRG --name myWebApp \
  --query principalId --output tsv)

# ── Azure Function ────────────────────────────────────────────────────
az functionapp identity assign \
  --resource-group myRG \
  --name myFunctionApp

# ── Azure Container App ───────────────────────────────────────────────
az containerapp identity assign \
  --resource-group myRG \
  --name myContainerApp \
  --system-assigned

# ── Grant identity access to Key Vault secrets ────────────────────────
az role assignment create \
  --assignee "$PRINCIPAL_ID" \
  --role "Key Vault Secrets User" \
  --scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/<vault>"

# ── Grant identity access to Service Bus (send + receive) ─────────────
az role assignment create \
  --assignee "$PRINCIPAL_ID" \
  --role "Azure Service Bus Data Owner" \
  --scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.ServiceBus/namespaces/<ns>"

Use in .NET 8 Code

csharp
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Azure.Messaging.ServiceBus;

// DefaultAzureCredential tries credentials in order:
//  1. Environment variables (CI/CD service principal)
//  2. Workload Identity (AKS pods)
//  3. Managed Identity (App Service, Functions, VMs)
//  4. Azure CLI (local dev)
//  5. Visual Studio / VS Code (local dev)
var credential = new DefaultAzureCredential();

// ── Key Vault ─────────────────────────────────────────────────────────
var kvClient = new SecretClient(
    new Uri("https://myvault.vault.azure.net/"),
    credential);

KeyVaultSecret secret = await kvClient.GetSecretAsync("db-connection-string");
string connectionString = secret.Value;

// ── Service Bus ───────────────────────────────────────────────────────
var sbClient = new ServiceBusClient(
    "<namespace>.servicebus.windows.net",
    credential); // No connection string — Managed Identity token

// ── Blob Storage ──────────────────────────────────────────────────────
var blobClient = new BlobServiceClient(
    new Uri("https://mystorageaccount.blob.core.windows.net/"),
    credential);

// ── Azure SQL (with Managed Identity token) ───────────────────────────
// In connection string: Authentication=Active Directory Managed Identity
// Or in EF Core:
services.AddDbContext<AppDbContext>((sp, options) =>
{
    var conn = new SqlConnection(configuration["Database:ConnectionString"]);
    conn.AccessToken = await new DefaultAzureCredential()
        .GetTokenAsync(new TokenRequestContext(
            ["https://database.windows.net/.default"]));
    options.UseSqlServer(conn);
});

02bUser-Assigned Identity

A user-assigned managed identity is an independent Azure resource that you create, name, and manage separately from the services that use it. You can attach the same identity to multiple resources — for example, three Function Apps and two App Services can all authenticate as the same principal. This is powerful for platform teams who want to pre-provision identities with specific RBAC roles and then assign them to new resources as they are deployed. Use user-assigned identities when you need shared credentials across services, predictable principal IDs for infrastructure-as-code, or when the identity must survive resource recreation during blue-green deployments.

bash
# 1. Create user-assigned managed identity (independent resource)
az identity create \
  --resource-group myRG \
  --name platform-reader-identity

# Capture identifiers
CLIENT_ID=$(az identity show -g myRG -n platform-reader-identity --query clientId -o tsv)
PRINCIPAL_ID=$(az identity show -g myRG -n platform-reader-identity --query principalId -o tsv)

# 2. Assign roles ONCE — shared by all resources using this identity
az role assignment create \
  --assignee "$PRINCIPAL_ID" \
  --role "Key Vault Secrets User" \
  --scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/myVault"

# 3. Attach the same identity to multiple resources
az webapp identity assign \
  --resource-group myRG \
  --name myWebApp \
  --identities /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.ManagedIdentity/userAssignedIdentities/platform-reader-identity

az functionapp identity assign \
  --resource-group myRG \
  --name myFunctionApp \
  --identities /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.ManagedIdentity/userAssignedIdentities/platform-reader-identity
csharp
// Use a specific user-assigned identity when the resource has multiple
var credential = new ManagedIdentityCredential(
    clientId: "<user-assigned-client-id>");

// Or with DefaultAzureCredential
var credential = new DefaultAzureCredential(
    new DefaultAzureCredentialOptions
    {
        ManagedIdentityClientId = "<user-assigned-client-id>"
    });

var kvClient = new SecretClient(
    new Uri("https://myvault.vault.azure.net/"),
    credential);

02cManaged Identity Integration Patterns

Every Azure service that supports RBAC can be accessed via Managed Identity — you just need to know which built-in role to assign and at what scope. The table below maps common Azure services to their required roles, helping you implement least-privilege access without guesswork. Always assign roles at the narrowest scope possible: a specific queue rather than the entire Service Bus namespace, or a single container rather than the whole storage account. This limits the blast radius if an identity is ever compromised and makes your audit trail far more meaningful.

Azure ServiceRole to AssignScope
Azure Key Vault (read secrets)Key Vault Secrets UserVault or specific secret
Azure Key Vault (manage secrets)Key Vault Secrets OfficerVault
Azure Service Bus (send)Azure Service Bus Data SenderNamespace or queue/topic
Azure Service Bus (receive)Azure Service Bus Data ReceiverNamespace or queue/topic
Azure Blob Storage (read)Storage Blob Data ReaderAccount, container, or blob
Azure Blob Storage (write)Storage Blob Data ContributorAccount or container
Azure SQL Databasedb_datareader / db_datawriter (via SQL GRANT)Database user mapped to MI
Azure Event Hubs (produce)Azure Event Hubs Data SenderNamespace or hub
Azure Event Hubs (consume)Azure Event Hubs Data ReceiverNamespace or hub
Azure Event Grid (publish)EventGrid Data SenderTopic
Azure Container Registry (pull)AcrPullRegistry
Azure API ManagementAPI Management Service ReaderAPIM instance
Golden Rule — Least PrivilegeAlways assign the narrowest scope possible. GrantKey Vault Secrets User on a specific secret URI rather than the whole vault. GrantStorage Blob Data Reader on a container rather than the whole storage account. This limits blast radius if the identity is compromised.

AKS Workload Identity (Pod-Level)

bash
# Enable OIDC issuer and Workload Identity on AKS
az aks update \
  --resource-group myRG \
  --name myAKS \
  --enable-oidc-issuer \
  --enable-workload-identity

# Get the OIDC issuer URL
OIDC_ISSUER=$(az aks show -g myRG -n myAKS --query "oidcIssuerProfile.issuerUrl" -o tsv)

# Create federated credential — links K8s service account to Entra ID app
az identity federated-credential create \
  --name aks-orders-api \
  --identity-name platform-reader-identity \
  --resource-group myRG \
  --issuer "$OIDC_ISSUER" \
  --subject "system:serviceaccount:myplatform:orders-api-sa" \
  --audience api://AzureADTokenExchange
yaml
# k8s/orders-serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-api-sa
  namespace: myplatform
  annotations:
    azure.workload.identity/client-id: "<user-assigned-client-id>"
---
# Reference in Deployment
spec:
  template:
    metadata:
      labels:
        azure.workload.identity/use: "true"   # Inject OIDC token volume
    spec:
      serviceAccountName: orders-api-sa

03Key Vault & Secrets Management

Azure Key Vault is the single source of truth for all credentials. Secrets (connection strings, API keys), Keys (RSA/EC for encrypt/sign), and Certificates (X.509 with lifecycle management) are stored, versioned, audited, and accessed via Managed Identity — never via hard-coded credentials.

What to StoreKey Vault Object TypeNotes
Database connection stringsSecretUse Key Vault Reference in App Settings
Third-party API keysSecretSet expiry + rotation policy
OAuth client secretsSecretRotate on schedule — Key Vault tracks versions
JWT signing keys (symmetric)SecretOr use Key type for RSA signing
JWT signing keys (asymmetric)KeyRSA 2048/4096 — sign inside KV, key never leaves
TLS certificatesCertificateAuto-renew with DigiCert / GlobalSign
Storage account keysSecretPrefer Managed Identity instead where possible
Encryption keys (DEK wrap)KeyEnvelope encryption — AES DEK wrapped by KV key

03aAccessing Secrets at Runtime

There are multiple patterns for retrieving secrets from Key Vault, each suited to different scenarios. Key Vault References let App Service and Functions resolve secrets at startup with zero application code — the platform injects them as environment variables. For more control, the Azure SDK's SecretClient provides programmatic access with caching to minimize API calls and latency. In production, always combine SDK access with an in-memory cache (5–30 minutes TTL) to avoid hitting Key Vault's throttling limits of 4,000 transactions per 10 seconds per vault.

Pattern 1 — Key Vault References (Zero Code)

bash
# Best pattern: reference in App Settings — platform resolves at runtime
# No SDK call needed — value injected as environment variable

az webapp config appsettings set \
  --resource-group myRG \
  --name myWebApp \
  --settings \
    "DB_CONNECTION=@Microsoft.KeyVault(VaultName=myVault;SecretName=db-connstr)" \
    "STRIPE_KEY=@Microsoft.KeyVault(VaultName=myVault;SecretName=stripe-api-key)" \
    "JWT_SECRET=@Microsoft.KeyVault(VaultName=myVault;SecretName=jwt-signing-key)"

# Verify resolution status
az webapp config appsettings list \
  --resource-group myRG --name myWebApp \
  --query "[?contains(name,'DB_CONNECTION')]"

Pattern 2 — SDK Access with Caching

csharp
// Register Key Vault as a configuration provider (.NET 8)
// Secrets automatically available via IConfiguration["secret-name"]
builder.Configuration.AddAzureKeyVault(
    new Uri("https://myvault.vault.azure.net/"),
    new DefaultAzureCredential(),
    new AzureKeyVaultConfigurationOptions
    {
        ReloadInterval = TimeSpan.FromMinutes(30) // Poll for updated secrets
    });

// Access via IConfiguration — works like any other config value
var dbConnStr = builder.Configuration["db-connstr"];
var jwtSecret = builder.Configuration["jwt-signing-key"];

// ── Or inject the SecretClient directly ──────────────────────────────
services.AddSingleton(sp => new SecretClient(
    new Uri("https://myvault.vault.azure.net/"),
    new DefaultAzureCredential()));

// ── Usage in a service ────────────────────────────────────────────────
public class ApiKeyService(SecretClient kvClient, IMemoryCache cache)
{
    public async Task<string> GetApiKeyAsync(string service, CancellationToken ct = default)
    {
        var cacheKey = $"apikey:{service}";

        // Cache for 10 minutes — reduces KV operation count
        return await cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            var secret = await kvClient.GetSecretAsync(service, cancellationToken: ct);
            return secret.Value.Value;
        }) ?? throw new InvalidOperationException($"Secret {service} not found");
    }
}

Pattern 3 — Secure HttpClient Factory

csharp
// Inject API keys into typed HTTP clients via KV — retrieved once at startup
services.AddHttpClient<IStripeClient, StripeClient>()
    .ConfigureHttpClient(async (sp, client) =>
    {
        var kv = sp.GetRequiredService<SecretClient>();
        var key = await kv.GetSecretAsync("stripe-api-key");
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", key.Value.Value);
        client.BaseAddress = new Uri("https://api.stripe.com/v1/");
    });

03bSecret Rotation

Key Vault emits Microsoft.KeyVault.SecretNearExpiry andMicrosoft.KeyVault.SecretExpired events via Event Grid. A Function App subscribes and executes a zero-downtime rotation: generate new credential → update source system → write new KV version → apps using the latest-URI automatically pick it up.

csharp
// Function triggered by Key Vault Event Grid event
[Function("RotateDbPassword")]
public async Task Run(
    [EventGridTrigger] EventGridEvent evt,
    ILogger<RotateDbPassword> log)
{
    if (evt.EventType != "Microsoft.KeyVault.SecretNearExpiry") return;

    var secretName = evt.Subject; // e.g. "orders-db-password"
    log.LogInformation("Rotating secret: {SecretName}", secretName);

    var kvClient = new SecretClient(
        new Uri("https://myvault.vault.azure.net/"),
        new DefaultAzureCredential());

    // 1. Generate new credential at the database
    var newPassword = await _dbAdmin.RotatePasswordAsync(secretName);

    // 2. Write new version to Key Vault
    var newSecret = new KeyVaultSecret(secretName, newPassword);
    newSecret.Properties.ExpiresOn = DateTimeOffset.UtcNow.AddDays(90);
    newSecret.Properties.Tags["RotatedAt"] = DateTime.UtcNow.ToString("o");

    await kvClient.SetSecretAsync(newSecret);

    // 3. Apps calling GetSecretAsync("<name>") without a version
    //    automatically receive the new version on next call.
    log.LogInformation("Secret {SecretName} rotated. New version created.", secretName);
}

// Set up rotation policy on the secret (automate reminders)
// via CLI:
// az keyvault secret set-attributes
//   --vault-name myVault --name orders-db-password
//   --expires "$(date -u -d '+90 days' '+%Y-%m-%dT%H:%M:%SZ')"
//   --tags "RotationPolicy=90days"

04OAuth 2.0 Fundamentals

OAuth 2.0 is the authorization framework that allows applications to obtain limited access to user accounts or services without exposing credentials. OpenID Connect (OIDC) is a thin identity layer on top that adds authentication via an id_token. Together they underpin every modern Azure authentication scenario.

TermDefinition
Resource OwnerThe user (or system) that owns the data
ClientThe application requesting access (your web app, SPA, mobile app, daemon)
Authorization ServerEntra ID — issues tokens after authenticating the user/client
Resource ServerYour API — validates tokens and serves protected data
Access TokenShort-lived credential proving authorization — sent in Authorization header
Refresh TokenLong-lived token to obtain new access tokens without re-authentication
ID TokenOIDC token containing user identity claims — for the client, not the API
ScopeRequested permission — e.g. openid, profile, email, api://myapi/Orders.Read
Authorization CodeShort-lived one-time code exchanged for tokens at token endpoint
Client CredentialsApp-only flow — no user. Service-to-service authentication.
PKCEProof Key for Code Exchange — prevents auth code interception in public clients

Token Endpoint Calls

text
Authorization Endpoint  (GET)
  https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize

Token Endpoint  (POST)
  https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token

JWKS (public keys for JWT verification)
  https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys

OpenID Configuration (discovery document)
  https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration

04aGrant Flows Deep Dive

OAuth 2.0 defines several grant flows, each designed for a specific client type and interaction model. Choosing the wrong flow introduces security vulnerabilities — for example, using Implicit flow in a SPA exposes tokens in the URL. The Authorization Code + PKCE flow is now the universal recommendation for any interactive scenario (web apps, SPAs, mobile), while Client Credentials is the standard for machine-to-machine communication. Understanding when to use On-Behalf-Of versus Client Credentials is critical for microservice architectures where APIs call other APIs while preserving (or not) the user's identity context.

FlowUse CaseHas User?Refresh Token?
Authorization Code + PKCEWeb apps, SPAs, mobile apps✓ Yes✓ Yes
Client CredentialsDaemons, microservices, CI/CD pipelines✗ No✗ No
Device CodeCLI tools, IoT, TV apps with no browser✓ Yes✓ Yes
On-Behalf-Of (OBO)Middle-tier API calling downstream API as user✓ Yes✓ Yes
Implicit (deprecated)Legacy SPAs — do not use for new apps✓ Yes✗ No
Resource Owner Password (ROPC)Legacy migration only — avoid entirely✓ Yes✓ Yes

Flow 1 — Authorization Code + PKCE (Web App)

text
STEP 1 — User clicks "Login" — browser redirects to Entra ID
GET https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize
  ?client_id=<app-id>
  &response_type=code
  &redirect_uri=https://myapp.com/auth/callback
  &scope=openid profile email api://myapi/Orders.Read
  &state=<random-csrf-token>
  &code_challenge=<base64url(sha256(code_verifier))>
  &code_challenge_method=S256

STEP 2 — User authenticates at Entra ID
         (MFA, Conditional Access evaluated here)

STEP 3 — Entra ID redirects back with authorization code
GET https://myapp.com/auth/callback
  ?code=<authorization-code>
  &state=<same-state>

STEP 4 — Server exchanges code for tokens (back-channel — never exposed to browser)
POST https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token
  Content-Type: application/x-www-form-urlencoded

  grant_type=authorization_code
  &client_id=<app-id>
  &client_secret=<client-secret>      ← confidential client only
  &code=<authorization-code>
  &redirect_uri=https://myapp.com/auth/callback
  &code_verifier=<original-random-string>   ← PKCE verifier

STEP 5 — Entra ID returns tokens
{
  "access_token":  "<jwt>",          ← Send to APIs
  "id_token":      "<jwt>",          ← User identity (for the app)
  "refresh_token": "<opaque>",       ← Get new access tokens silently
  "expires_in":    3600,
  "token_type":    "Bearer"
}

Flow 2 — Client Credentials (Service-to-Service)

csharp
// .NET 8 — MSAL Client Credentials (app-only token)
// NuGet: Microsoft.Identity.Client

var app = ConfidentialClientApplicationBuilder
    .Create("<client-id>")
    .WithClientSecret("<client-secret>")          // Or .WithCertificate(cert)
    .WithAuthority($"https://login.microsoftonline.com/<tenant-id>")
    .Build();

var result = await app.AcquireTokenForClient(
    scopes: ["api://target-api/.default"])  // .default = all app permissions
    .ExecuteAsync();

string accessToken = result.AccessToken;

// ── Or via DefaultAzureCredential (Managed Identity — no secret) ───────
var credential  = new DefaultAzureCredential();
var tokenCtx    = new TokenRequestContext(["api://target-api/.default"]);
var tokenResult = await credential.GetTokenAsync(tokenCtx);

// ── Attach to HttpClient ──────────────────────────────────────────────
httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", tokenResult.Token);

Flow 3 — On-Behalf-Of (API calling API as User)

csharp
// Orders API received user's access token.
// Needs to call Inventory API AS the user (delegated permissions).

[Authorize]
public async Task<IActionResult> GetOrderWithInventory(Guid orderId)
{
    // Get the inbound user token from the request
    string inboundToken = HttpContext.GetBearerToken()!;

    // Exchange it for a token scoped to the Inventory API (OBO flow)
    var confidentialApp = ConfidentialClientApplicationBuilder
        .Create("<orders-api-client-id>")
        .WithClientSecret("<orders-api-client-secret>")
        .WithAuthority($"https://login.microsoftonline.com/<tenant>")
        .Build();

    var userAssertion = new UserAssertion(inboundToken);
    var result = await confidentialApp
        .AcquireTokenOnBehalfOf(
            scopes: ["api://inventory-api/Inventory.Read"],
            userAssertion: userAssertion)
        .ExecuteAsync();

    // Call downstream API with the OBO token
    _httpClient.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", result.AccessToken);

    var inventory = await _httpClient.GetFromJsonAsync<InventoryData>(
        $"/api/products/{orderId}/inventory");

    return Ok(new { order = await GetOrderAsync(orderId), inventory });
}

04bPKCE & SPA Security

Single-Page Applications are public clients — they cannot safely store a client secret. PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks by binding the code to a one-time verifier known only to the legitimate client.

typescript
// SPA — MSAL.js 3 with PKCE (automatic)
// NuGet/npm: @azure/msal-browser

import { PublicClientApplication, Configuration } from "@azure/msal-browser";

const msalConfig: Configuration = {
  auth: {
    clientId:    "<spa-app-client-id>",
    authority:   "https://login.microsoftonline.com/<tenant-id>",
    redirectUri: window.location.origin,
  },
  cache: {
    cacheLocation: "sessionStorage",     // sessionStorage > localStorage for security
    storeAuthStateInCookie: false,
  },
};

const msalInstance = new PublicClientApplication(msalConfig);
await msalInstance.initialize();

// Login — MSAL handles PKCE code_verifier/challenge automatically
const loginResult = await msalInstance.loginPopup({
  scopes: ["openid", "profile", "api://myapi/Orders.Read"],
});

// Silently get an access token for the API (uses refresh token / cache)
const tokenResult = await msalInstance.acquireTokenSilent({
  scopes: ["api://myapi/Orders.Read"],
  account: msalInstance.getAllAccounts()[0],
});

// Attach to fetch call
const response = await fetch("https://myapi.com/api/orders", {
  headers: {
    Authorization: `Bearer ${tokenResult.accessToken}`,
    "Content-Type": "application/json",
  },
});
⚠️
Never Use Implicit Flow for New SPAsThe Implicit flow returns tokens directly in the URL fragment — they are visible in browser history and server logs. Always use Authorization Code + PKCE for SPAs. Microsoft deprecated Implicit flow for new app registrations.

05JWT — Structure & Validation

A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe token format defined in RFC 7519. An access token from Entra ID is always a signed JWT. It consists of three Base64URL-encoded JSON objects separated by dots:header.payload.signature.

Anatomy of a JWT

json
// ── HEADER (algorithm + key identifier) ─────────────────────────────
{
  "typ": "JWT",
  "alg": "RS256",     // RSA-SHA256 — Entra ID always uses RS256 for access tokens
  "kid": "abc123..."  // Key ID — matches a public key in the JWKS endpoint
}

// ── PAYLOAD (claims) ─────────────────────────────────────────────────
{
  // Standard registered claims (RFC 7519)
  "iss": "https://login.microsoftonline.com/<tenant-id>/v2.0",  // Issuer
  "sub": "user-object-id",                                      // Subject (user or app)
  "aud": "api://myapi",                                         // Audience — MUST match your API
  "exp": 1746999999,                                            // Expiry (Unix timestamp)
  "iat": 1746996399,                                            // Issued At
  "nbf": 1746996399,                                            // Not Before

  // Entra ID specific claims
  "tid":   "<tenant-id>",           // Tenant ID
  "oid":   "<user-object-id>",      // Unique user object ID (stable across apps)
  "azp":   "<client-id>",           // Authorized party (client app ID)
  "azpacr":"1",                     // Client auth method: 0=public, 1=secret, 2=cert
  "ver":   "2.0",                   // Token version

  // User identity claims (with openid + profile scope)
  "name":               "Alice Smith",
  "preferred_username": "alice@contoso.com",
  "email":              "alice@contoso.com",
  "given_name":         "Alice",
  "family_name":        "Smith",

  // Authorization claims
  "scp":   "Orders.Read Orders.Write",  // Delegated scopes (user present)
  "roles": ["Orders.Admin"],            // App roles assigned to user/app

  // OIDC nonce (when id_token)
  "nonce": "<random-value>",

  // Continuous Access Evaluation
  "xms_cc": { "values": ["cp1"] }    // CAE-capable client
}

// ── SIGNATURE ────────────────────────────────────────────────────────
// RS256: RSA signature of base64url(header) + "." + base64url(payload)
// Verify using public key from:
// https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys

05aStandard & Custom Claims

Claims are the key-value pairs inside a JWT payload that carry identity and authorization information. Standard claims like iss, aud, and exp are defined by RFC 7519 and must always be validated by your API. Entra ID adds its own claims — oid for stable user identity, scp for delegated permissions, and roles for app role assignments. Understanding which claims to check and how to extract them is the foundation of fine-grained authorization in your APIs — without proper claim validation, your token verification is incomplete.

ClaimTypeDescriptionUse For
issStandardToken issuer — Entra ID URL with tenantValidate issuer in API
subStandardSubject — unique user/app identifierUser identity key (prefer oid for Entra)
audStandardAudience — intended recipient(s)MUST match your API's App ID URI
expStandardExpiry timestamp (Unix)Reject expired tokens
iatStandardIssued-at timestampToken age checks
nbfStandardNot-before timestampToken not valid before this time
oidEntra IDUnique user object ID across appsStable user identifier — use as primary key
tidEntra IDTenant IDMulti-tenant: validate expected tenant
scpEntra IDSpace-separated delegated scopesCheck user granted required permission
rolesEntra IDApp roles arrayCheck user/app has required role
azpEntra IDClient app ID that requested tokenValidate calling app in S2S flows
acr/amrEntra IDAuth context / methods (MFA, pwd, cert)Step-up auth enforcement
ctryCustom opt.User's country (from directory)Geo-based access control
groupsEntra ID opt.Group membership GUIDsGroup-based RBAC (overage possible)
💡
oid vs sub for User IdentityAlways use oid (not sub) as the stable user identifier in Entra ID. The sub claim is pairwise — it differs across apps. oid is the same for a user across all apps in your tenant.

05bServer-Side JWT Validation (.NET 8)

Server-side JWT validation is the most critical security gate in your API — every incoming request must have its token verified before any business logic executes. The .NET 8 JWT Bearer middleware handles this automatically: it downloads Entra ID's public signing keys from the JWKS endpoint, validates the RS256 signature, checks issuer and audience, and rejects expired tokens. Beyond the standard middleware, you should implement policy-based authorization to enforce fine-grained access control based on scopes and roles. For non-HTTP scenarios like message consumers or background workers, manual validation with JsonWebTokenHandler gives you the same security guarantees outside the ASP.NET Core pipeline.

Standard JWT Bearer Middleware

csharp
// Program.cs — Orders API
var tenantId = builder.Configuration["AzureAd:TenantId"]!;
var audience  = builder.Configuration["AzureAd:Audience"]!; // api://myorders-api

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Authority = Entra ID OIDC discovery URL
        // Middleware auto-downloads signing keys from JWKS endpoint
        options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";

        // Audience validation — CRITICAL — prevents token confusion attacks
        options.Audience = audience;

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,  // Verify RS256 signature
            ValidateIssuer           = true,  // Verify iss matches authority
            ValidateAudience         = true,  // Verify aud matches our API
            ValidateLifetime         = true,  // Reject expired tokens
            ClockSkew                = TimeSpan.FromMinutes(5), // Tolerate 5-min clock drift

            // For multi-tenant apps — accept tokens from multiple tenants
            // ValidIssuers = new[] {
            //   $"https://login.microsoftonline.com/{tenantId}/v2.0",
            //   $"https://login.microsoftonline.com/common/v2.0",
            // }
        };

        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = ctx =>
            {
                // Additional custom validation after standard checks
                var tid = ctx.Principal!.FindFirst("tid")?.Value;
                if (tid != tenantId)
                {
                    ctx.Fail("Token from unexpected tenant");
                }
                return Task.CompletedTask;
            },
            OnAuthenticationFailed = ctx =>
            {
                logger.LogWarning("JWT auth failed: {Error}", ctx.Exception.Message);
                return Task.CompletedTask;
            },
            OnChallenge = ctx =>
            {
                // Custom 401 body
                ctx.HandleResponse();
                ctx.Response.StatusCode  = 401;
                ctx.Response.ContentType = "application/problem+json";
                return ctx.Response.WriteAsJsonAsync(new
                {
                    type   = "https://tools.ietf.org/html/rfc7235#section-3.1",
                    title  = "Unauthorized",
                    status = 401,
                    detail = "A valid Bearer token is required"
                });
            }
        };
    });

Policy-Based Authorization

csharp
// Define policies in Program.cs
builder.Services.AddAuthorizationBuilder()
    // Delegated (user present) — checks scp claim
    .AddPolicy("Orders.Read", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("scp", "Orders.Read"))

    .AddPolicy("Orders.Write", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("scp", "Orders.Write"))

    // App role — checks roles claim
    .AddPolicy("OrdersAdmin", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("roles", "Orders.Admin"))

    // Service-to-service — app-only token (no user)
    .AddPolicy("ServiceToService", policy =>
        policy.RequireClaim("azp")
              .RequireClaim("idtyp", "app"))

    // Combined: must have scope AND be from expected tenant
    .AddPolicy("TrustedOrders", policy =>
        policy.RequireAuthenticatedUser()
              .RequireClaim("scp", "Orders.Read")
              .RequireClaim("tid", tenantId));

// Apply to endpoints
group.MapGet("/",          GetOrders).RequireAuthorization("Orders.Read");
group.MapPost("/",         CreateOrder).RequireAuthorization("Orders.Write");
group.MapDelete("/{id}",   DeleteOrder).RequireAuthorization("OrdersAdmin");
group.MapGet("/internal",  InternalGet).RequireAuthorization("ServiceToService");

// ── Reading claims in handlers ────────────────────────────────────────
private static async Task<IResult> CreateOrderAsync(
    CreateOrderCommand cmd,
    ClaimsPrincipal user,   // Injected automatically from HttpContext.User
    ISender sender,
    CancellationToken ct)
{
    var userId   = user.FindFirstValue("oid")!;       // Stable user ID
    var tenantId = user.FindFirstValue("tid")!;
    var scopes   = user.FindFirstValue("scp")!;       // Granted scopes

    var enrichedCmd = cmd with { RequestedBy = userId };
    var result = await sender.Send(enrichedCmd, ct);
    return result.IsSuccess ? Results.Created(...) : Results.Problem(...);
}

Manual JWT Validation (Without Middleware)

csharp
// For scenarios like background services, message consumers,
// or validating tokens outside of HTTP pipeline

public class JwtValidator(IConfiguration config)
{
    public async Task<ClaimsPrincipal> ValidateAsync(string token)
    {
        var tenantId = config["AzureAd:TenantId"]!;
        var audience  = config["AzureAd:Audience"]!;

        // Download JWKS (signing keys) from Entra ID discovery endpoint
        var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
            $"https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration",
            new OpenIdConnectConfigurationRetriever());

        var oidcConfig = await configManager.GetConfigurationAsync();

        var validationParams = new TokenValidationParameters
        {
            ValidIssuer              = $"https://login.microsoftonline.com/{tenantId}/v2.0",
            ValidAudience            = audience,
            IssuerSigningKeys        = oidcConfig.SigningKeys, // RS256 public keys
            ValidateIssuerSigningKey = true,
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ClockSkew                = TimeSpan.FromMinutes(5),
        };

        var handler    = new JsonWebTokenHandler();
        var result     = handler.ValidateToken(token, validationParams);

        if (!result.IsValid)
            throw new SecurityTokenException($"Token invalid: {result.Exception?.Message}");

        return new ClaimsPrincipal(result.ClaimsIdentity);
    }
}

05cRefresh Token Strategy

Access tokens are intentionally short-lived (typically 1 hour) to limit the damage window if one is stolen. Refresh tokens allow your application to obtain new access tokens silently without forcing the user to re-authenticate. However, refresh tokens are high-value targets — if compromised, an attacker can mint new access tokens indefinitely. The storage strategy for each token type is a critical security decision: access tokens belong in memory only, refresh tokens in server-side secure storage, and session cookies must use HttpOnly + Secure + SameSite=Strict flags to prevent XSS and CSRF attacks.

Token TypeLifetime (Entra ID Default)Storage Guidance
Access Token1 hourMemory only — never localStorage, never cookie without HttpOnly+Secure+SameSite=Strict
Refresh Token24 hours (sliding)HttpOnly Secure cookie (web app server-side) or secure storage (mobile)
ID Token1 hourMemory only — only used for user info, never sent to APIs
Session CookieConfigurableSet by Entra ID — controls SSO session length
csharp
// ASP.NET Core web app — silent token refresh with MSAL
// NuGet: Microsoft.Identity.Web

// Program.cs
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"))
    .EnableTokenAcquisitionToCallDownstreamApi(
        initialScopes: ["api://myapi/Orders.Read"])
    .AddDistributedTokenCaches();  // Redis-backed token cache for scale-out

// In a controller / Minimal API handler
app.MapGet("/orders", async (ITokenAcquisition tokenAcquisition) =>
{
    // MSAL handles refresh silently — throws MsalUiRequiredException if re-auth needed
    string token = await tokenAcquisition.GetAccessTokenForUserAsync(
        scopes: ["api://myapi/Orders.Read"]);

    // Use token to call downstream API
    _httpClient.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", token);

    return await _httpClient.GetFromJsonAsync<List<Order>>("/api/v1/orders");
})
.RequireAuthorization();
🚨
Access Token Storage — Critical RulesSPAs: Store access tokens in memory only (JS variable). Never in localStorage (XSS risk) or regular cookies (CSRF risk). Use MSAL.js which handles this correctly with sessionStorage for state.

Web Apps (server-side): Store refresh tokens in a server-side token cache (Redis). Never expose them to the browser. Use HttpOnly + Secure + SameSite=Strict cookies for the session.

06Microsoft Entra ID

Microsoft Entra ID (formerly Azure Active Directory) is the cloud identity platform that underpins all Azure authentication. It manages users, groups, applications, service principals, conditional access policies, and issues OAuth 2.0 / OIDC tokens. Every Azure service, every API, and every application you build integrates with Entra ID as its identity authority.

ConceptDefinition
TenantAn Entra ID organisation. Your company's directory. Identified by a GUID tenant ID.
App RegistrationDeclares your application to Entra ID. Defines redirect URIs, scopes, roles, and required permissions.
Service PrincipalThe runtime identity of an app registration within a specific tenant.
Managed IdentityA special service principal managed by Azure — no credential management needed.
Enterprise AppA service principal with additional configuration: SSO settings, user assignments, provisioning.
ScopeA permission an application can request on behalf of a user (delegated).
App RoleA permission assigned to a user/group or app (app-to-app). Present as the roles claim in tokens.
Conditional AccessPolicies enforced at login: require MFA, block risky sign-ins, restrict to managed devices.
PIMPrivileged Identity Management — just-in-time, time-limited elevation to privileged roles.

06aApp Registrations

For a typical microservices platform, you need separate app registrationsfor each logical boundary — one per API, one per frontend application. Never share an app registration between a backend API and a frontend client.

RegistrationTypePurpose
Orders APIAPI (Resource)Defines scopes and app roles. Validates incoming tokens.
Payments APIAPI (Resource)Defines its own scopes. Only accessible from authorized clients.
Web App (frontend)Confidential clientAuth Code + PKCE flow. Has client secret. Requests scopes from APIs.
SPA (React/Vue)Public clientAuth Code + PKCE, no secret. Requests delegated scopes.
CLI / DevOps toolPublic clientDevice Code flow or Client Credentials.
Background daemonConfidential clientClient Credentials flow. Has app role assignments.

Create App Registration via CLI

bash
# ── Register the Orders API ──────────────────────────────────────────
az ad app create \
  --display-name "Orders API" \
  --identifier-uris "api://orders-api" \
  --sign-in-audience AzureADMyOrg

ORDERS_APP_ID=$(az ad app list --display-name "Orders API" --query "[0].appId" -o tsv)

# Add delegated scopes (permissions users can grant)
az ad app update --id "$ORDERS_APP_ID" --set api='{
  "oauth2PermissionScopes": [
    {
      "id": "'$(uuidgen)'",
      "value": "Orders.Read",
      "type": "User",
      "adminConsentDisplayName": "Read orders",
      "adminConsentDescription": "Allows reading orders on behalf of the user",
      "isEnabled": true
    },
    {
      "id": "'$(uuidgen)'",
      "value": "Orders.Write",
      "type": "User",
      "adminConsentDisplayName": "Create and update orders",
      "adminConsentDescription": "Allows creating and updating orders on behalf of the user",
      "isEnabled": true
    }
  ]
}'

# Add app roles (permissions apps or users can be assigned)
az ad app update --id "$ORDERS_APP_ID" --set appRoles='[
  {
    "id": "'$(uuidgen)'",
    "value": "Orders.Admin",
    "displayName": "Orders Administrator",
    "description": "Can manage all orders and configuration",
    "isEnabled": true,
    "allowedMemberTypes": ["User", "Application"]
  }
]'

# ── Register the Web App (confidential client) ────────────────────────
az ad app create \
  --display-name "Orders Web App" \
  --web-redirect-uris "https://myapp.com/auth/callback" \
  --sign-in-audience AzureADMyOrg

WEB_APP_ID=$(az ad app list --display-name "Orders Web App" --query "[0].appId" -o tsv)

# Create client secret
az ad app credential reset \
  --id "$WEB_APP_ID" \
  --display-name "prod-secret" \
  --end-date "2027-01-01"

appsettings.json — Entra ID Configuration

json
{
  "AzureAd": {
    "Instance":    "https://login.microsoftonline.com/",
    "TenantId":    "<your-tenant-id>",
    "ClientId":    "<orders-api-app-id>",
    "Audience":    "api://orders-api",
    "ClientSecret": "@Microsoft.KeyVault(VaultName=myVault;SecretName=orders-api-client-secret)"
  }
}

06bScopes, Roles & Permissions

Entra ID supports two permission models: delegated scopes (user is present, app acts on their behalf) and app roles (assigned directly to users, groups, or applications). Delegated scopes appear in the scp claim and represent what the user has consented the app to do — they are the intersection of what the app requests and what the user is allowed. App roles appear in the roles claim and are assigned by an admin, making them ideal for role-based access control (RBAC) in your APIs. In practice, most production APIs use a combination: scopes for fine-grained user actions and roles for elevated administrative operations.

Permission TypeClaim in TokenGranted ByUse When
Delegated ScopescpUser consent or admin consentUser is present — app acts on user's behalf
App Role (User)rolesAdmin assigns role to user/groupRole-based access for users
App Role (App)rolesAdmin grants app permissionApp-to-app, service accounts, daemons
Admin-only ScopescpTenant admin consent onlySensitive data — user consent not enough

Checking Claims in Minimal API

csharp
// Extension methods for clean claim extraction
public static class ClaimsPrincipalExtensions
{
    // Get the stable user identity (use oid, not sub)
    public static string GetUserId(this ClaimsPrincipal principal)
        => principal.FindFirstValue("oid")
           ?? throw new UnauthorizedAccessException("oid claim missing");

    public static string GetTenantId(this ClaimsPrincipal principal)
        => principal.FindFirstValue("tid")
           ?? throw new UnauthorizedAccessException("tid claim missing");

    // Check delegated scope (user present)
    public static bool HasScope(this ClaimsPrincipal principal, string scope)
    {
        var scopes = principal.FindFirstValue("scp")?.Split(' ') ?? [];
        return scopes.Contains(scope, StringComparer.OrdinalIgnoreCase);
    }

    // Check app role (user or app-only)
    public static bool HasAppRole(this ClaimsPrincipal principal, string role)
    {
        var roles = principal.FindAll("roles").Select(c => c.Value);
        return roles.Contains(role, StringComparer.OrdinalIgnoreCase);
    }

    // Detect app-only token (no user — service-to-service)
    public static bool IsAppOnlyToken(this ClaimsPrincipal principal)
        => principal.FindFirstValue("idtyp") == "app";
}

// Usage in endpoint handler
private static async Task<IResult> GetSensitiveOrderAsync(
    Guid id, ClaimsPrincipal user, ISender sender, CancellationToken ct)
{
    // Fine-grained check beyond what the [Authorize] policy covers
    if (!user.HasScope("Orders.Read") && !user.HasAppRole("Orders.Admin"))
        return Results.Forbid();

    var userId = user.IsAppOnlyToken() ? "service-account" : user.GetUserId();
    // ...
}

06cEntra External ID (B2C)

Entra External ID (formerly Azure AD B2C) handles customer-facing identity — external users, social logins (Google, Facebook, Apple), local accounts, and custom sign-up/sign-in flows called user flows or custom policies (IEF).

FeatureEntra ID (workforce)Entra External ID (customers)
Primary usersEmployees, partnersConsumers, external customers
Social loginsLimited (via federation)Google, Facebook, Apple, GitHub built-in
Local accountsOrg accounts onlyEmail+password, phone
Custom UIBranded login pageFull custom HTML/CSS/JS via page templates
User flowsN/ASign-up/sign-in, password reset, profile edit
Custom policies (IEF)N/AComplex multi-step journeys, claims transforms
PricingPer active userPer MAU (monthly active user)
Token issuerlogin.microsoftonline.com/<tenant><tenant>.b2clogin.com/<tenant>/<policy>/v2.0

Validate B2C Token in .NET 8 API

csharp
// B2C uses a different authority format (policy-specific)
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer("B2C", options =>
    {
        var b2cTenant = builder.Configuration["AzureAdB2C:TenantName"]; // myb2ctenant
        var policy    = builder.Configuration["AzureAdB2C:SignInPolicy"]; // B2C_1_SignUpSignIn
        var clientId  = builder.Configuration["AzureAdB2C:ClientId"];

        options.Authority =
            $"https://{b2cTenant}.b2clogin.com/{b2cTenant}.onmicrosoft.com/{policy}/v2.0";

        options.Audience = clientId;  // B2C uses clientId as audience

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            NameClaimType            = "name",
            // B2C custom claims
            // "emails", "city", "postalCode", etc. per user flow config
        };
    });

06dContinuous Access Evaluation (CAE)

Normally, an access token remains valid until it expires (up to 1 hour) even if the user's account is disabled or their session revoked. CAE solves this by enabling near-real-time token revocation — Entra ID pushes revocation events to CAE-capable resource servers, which reject the token within minutes, not hours.

CAE EventTriggered WhenAPI Behavior
User account disabledAdmin disables user in Entra IDAPI rejects token within ~15 min
Password changeUser or admin resets passwordAll existing tokens invalidated
Session revokedAdmin revokes all user sessionsToken rejected on next API call
Location policy changeConditional Access location updatedToken rejected if user outside allowed location
Risk level elevatedIdentity Protection detects anomalyToken rejected if risk > policy threshold
csharp
// Declare your API as CAE-capable in JWT validation
// This tells Entra ID to send long-lived tokens (up to 28h)
// but allows near-real-time revocation

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // ... standard config ...

        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = ctx =>
            {
                // Check for CAE claims_challenge — client must re-authenticate
                var token = ctx.SecurityToken as JsonWebToken;
                var xmsCc = token?.Claims.FirstOrDefault(c => c.Type == "xms_cc");

                if (xmsCc?.Value.Contains("cp1") == true)
                {
                    // Client declared CAE-capable — long-lived token in use
                    // Entra ID will call your API's introspection endpoint on revocation
                    ctx.HttpContext.Items["CAE_Enabled"] = true;
                }
                return Task.CompletedTask;
            },
            OnChallenge = ctx =>
            {
                // If token rejected due to CAE event, respond with claims challenge
                // Client must request a fresh token with step-up auth
                if (ctx.AuthenticateFailure?.Message.Contains("Lifetime validation failed") == true)
                {
                    ctx.Response.Headers["WWW-Authenticate"] +=
                        ", claims="" + BuildClaimsChallenge() + """;
                }
                return Task.CompletedTask;
            }
        };
    });

07Zero Trust Architecture

Zero Trust assumes breach — trust nothing implicitly, verify everything explicitly, use least-privilege access, and assume every request could be malicious. The three core principles applied across all five pillars of this guide:

🔍
Verify Explicitly
Every API call validates the JWT (signature, issuer, audience, expiry, claims). Never trust the network alone. Every service-to-service call is authenticated with Managed Identity.
⚖️
Least Privilege
Grant only the minimum RBAC roles needed. Key Vault Secrets User not Administrator. Scope RBAC assignments to specific resources, not subscription-wide.
💥
Assume Breach
Isolate services with private endpoints. Log every Key Vault access. Monitor for anomalous token usage. Set short token lifetimes. Enable CAE for real-time revocation.

Zero Trust Checklist

ControlImplementationStatus
No hardcoded credentialsKey Vault References in App Settings✅ Required
No connection strings in codeManaged Identity for all Azure services✅ Required
JWT validated on every call.AddJwtBearer() with full parameter validation✅ Required
Audience validatedoptions.Audience set — prevents confused deputy✅ Required
Least-privilege RBACScope role assignments to specific resources✅ Required
Private endpointsKey Vault, Service Bus, Storage — no public access✅ Recommended
Managed Identity for all SAsReplace all connection strings with MI✅ Recommended
Soft delete + purge protectKey Vault — enabled on all production vaults✅ Recommended
Audit logs enabledKey Vault diagnostics → Log Analytics✅ Required
CAE enabledDeclare xms_cc claim support in APIs✅ Recommended
Conditional Access policiesMFA required, block legacy auth, device compliance✅ Recommended
PIM for admin rolesJust-in-time elevation, approval workflows✅ Recommended

08Securing APIs End-to-End

A production-ready API requires multiple security layers working together: authentication (JWT validation), authorization (scope and role policies), rate limiting (per-user throttling), CORS (browser origin control), and security headers (XSS, clickjacking, HSTS protection). The middleware pipeline order in ASP.NET Core is critical — authentication must run before authorization, and both must precede your endpoint handlers. The example below shows a complete, copy-paste-ready security setup that combines all the patterns from this guide into a single cohesive Program.cs, demonstrating how Key Vault configuration, JWT Bearer auth, policy-based authorization, and rate limiting compose together.

Complete Security Pipeline (.NET 8 Minimal API)

csharp
// Complete security setup — Orders.Api/Program.cs

var builder = WebApplication.CreateBuilder(args);

// ── 1. Configuration from Key Vault (no secrets in appsettings.json) ──
builder.Configuration.AddAzureKeyVault(
    new Uri("https://myvault.vault.azure.net/"),
    new DefaultAzureCredential());

// ── 2. Authentication — Entra ID JWT Bearer ────────────────────────────
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
        options.Audience  = "api://orders-api";
        options.TokenValidationParameters = new()
        {
            ValidateIssuerSigningKey = true,
            ValidateLifetime         = true,
            ClockSkew                = TimeSpan.FromMinutes(5),
        };
    });

// ── 3. Authorization policies ──────────────────────────────────────────
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Orders.Read",  p => p.RequireClaim("scp",   "Orders.Read"))
    .AddPolicy("Orders.Write", p => p.RequireClaim("scp",   "Orders.Write"))
    .AddPolicy("OrdersAdmin",  p => p.RequireClaim("roles", "Orders.Admin"))
    .AddPolicy("S2S",          p => p.RequireClaim("idtyp", "app"));

// ── 4. Rate Limiting ───────────────────────────────────────────────────
builder.Services.AddRateLimiter(o =>
{
    o.AddFixedWindowLimiter("perUser", opts =>
    {
        opts.Window        = TimeSpan.FromMinutes(1);
        opts.PermitLimit   = 100;
        opts.QueueLimit    = 10;
        opts.PartitionKey  = ctx => ctx.User.FindFirstValue("oid") ?? "anonymous";
    });
});

// ── 5. CORS (if browser clients) ───────────────────────────────────────
builder.Services.AddCors(o => o.AddPolicy("AllowSPA", p =>
    p.WithOrigins("https://myapp.com")
     .AllowAnyMethod()
     .WithHeaders("Authorization", "Content-Type")));

var app = builder.Build();

// ── 6. Security middleware pipeline (ORDER MATTERS) ────────────────────
app.UseHttpsRedirection();
app.UseCors("AllowSPA");
app.UseAuthentication();    // Must be before UseAuthorization
app.UseAuthorization();
app.UseRateLimiter();

// ── 7. Security response headers ──────────────────────────────────────
app.Use(async (ctx, next) =>
{
    ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
    ctx.Response.Headers["X-Frame-Options"]         = "DENY";
    ctx.Response.Headers["Referrer-Policy"]         = "no-referrer";
    ctx.Response.Headers["Permissions-Policy"]      = "geolocation=(), camera=()";
    ctx.Response.Headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
    await next();
});

// ── 8. Endpoint mapping with layered authorization ─────────────────────
var orders = app.MapGroup("/api/v1/orders")
    .RequireAuthorization()           // Must have valid token minimum
    .WithOpenApi()
    .RequireRateLimiting("perUser");

orders.MapGet("/",       GetOrders).RequireAuthorization("Orders.Read");
orders.MapPost("/",      CreateOrder).RequireAuthorization("Orders.Write");
orders.MapDelete("/{id}", DeleteOrder).RequireAuthorization("OrdersAdmin");

Problem Details for Auth Errors (RFC 7807)

csharp
// Return structured error responses for security failures
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Extensions["timestamp"] = DateTime.UtcNow;

        // Don't leak stack traces in production
        if (!ctx.HttpContext.RequestServices
            .GetRequiredService<IHostEnvironment>().IsDevelopment())
        {
            ctx.ProblemDetails.Extensions.Remove("exception");
        }
    };
});

// Custom 401 / 403 handlers
app.UseStatusCodePages(async ctx =>
{
    if (ctx.HttpContext.Response.StatusCode == 401)
    {
        await ctx.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Type   = "https://tools.ietf.org/html/rfc7235#section-3.1",
            Title  = "Unauthorized",
            Status = 401,
            Detail = "A valid Bearer token is required. Obtain one from Entra ID."
        });
    }
    else if (ctx.HttpContext.Response.StatusCode == 403)
    {
        await ctx.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Type   = "https://tools.ietf.org/html/rfc7231#section-6.5.3",
            Title  = "Forbidden",
            Status = 403,
            Detail = "Insufficient permissions. Required scope or role not present in token."
        });
    }
});

09Comparison Tables

Security decisions often come down to choosing between similar options — which OAuth flow, which token storage, which credential type. These comparison tables consolidate the trade-offs into a quick-reference format so you can make informed decisions without re-reading entire sections. Use them during architecture reviews, security audits, or when onboarding new team members who need to understand why your platform uses specific patterns. The recommendations reflect Microsoft's current best practices and real-world production experience across enterprise Azure deployments.

OAuth 2.0 Grant Flows — When to Use What

FlowClient TypeUser InteractionToken TypesRecommended?
Auth Code + PKCEPublic or confidentialBrowser redirectaccess + id + refresh✅ Yes — for all interactive
Client CredentialsConfidential onlyNone (machine)access only✅ Yes — for daemons/S2S
Device CodePublicDevice + secondary browseraccess + id + refresh✅ Yes — for CLI/IoT
On-Behalf-OfConfidential onlyVia inbound user tokenaccess + refresh✅ Yes — for API chains
ImplicitPublicBrowser redirectaccess + id (in URL)❌ Deprecated
ROPCConfidentialUsername/password formaccess + refresh❌ Avoid entirely

Token Storage — Security Comparison

Storage LocationXSS RiskCSRF RiskNotes
JS Memory (variable)Low — cleared on tab closeNone✅ Best for SPAs — MSAL default
sessionStorageMedium — accessible to same tab JSNone✅ Acceptable for SPAs — MSAL fallback
localStorageHigh — persists across tabs/sessionsNone❌ Never use for tokens
HttpOnly CookieNone — not accessible to JSMedium — use SameSite=Strict✅ Best for server-side web apps
Regular CookieNoneHigh❌ Never use — vulnerable to CSRF
Server-side session + RedisNoneLow (session ID in HttpOnly cookie)✅ Best for confidential clients

Managed Identity vs Service Principal vs Shared Key

ApproachCredential RotationSecret StorageAuditRecommended
Managed Identity (System)Automatic — platform managedNone neededEntra ID sign-in logs✅ First choice
Managed Identity (User)Automatic — platform managedNone neededEntra ID sign-in logs✅ When sharing across resources
Service Principal + SecretManual or automatedKey VaultEntra ID sign-in logs✅ CI/CD, cross-tenant
Service Principal + CertCertificate expiry managedKey VaultEntra ID sign-in logs✅ Higher assurance than secret
Connection String / SASManualKey Vault minimumStorage/SB logs⚠️ Legacy — migrate away
Shared Account KeyManualKey Vault minimumStorage diagnostic logs❌ Avoid — grants full account access

10Quick Reference Cheat Sheet

This cheat sheet collects the most frequently needed URLs, NuGet packages, claim extraction patterns, and scenario-to-pattern mappings in one place. Bookmark this section for daily development — it eliminates the need to search documentation for Entra ID endpoint URLs or remember which RBAC role to assign for a specific service. The scenario table at the bottom maps common real-world requirements directly to the recommended implementation pattern, giving you a decision tree for any new security integration you need to build.

Entra ID Endpoints
text
# Authorization endpoint (login redirect)
https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize

# Token endpoint (code exchange, client credentials, refresh)
https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token

# JWKS (public keys — for JWT signature verification)
https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys

# OpenID Connect discovery document
https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration

# Logout
https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/logout

# B2C authority (per policy)
https://<tenant>.b2clogin.com/<tenant>.onmicrosoft.com/<policy>/v2.0
Essential NuGet Packages
xml
<!-- JWT Bearer validation -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.*" />

<!-- Entra ID + MSAL web apps -->
<PackageReference Include="Microsoft.Identity.Web"                        Version="3.*" />
<PackageReference Include="Microsoft.Identity.Web.TokenCache"             Version="3.*" />

<!-- MSAL for token acquisition (daemon / background) -->
<PackageReference Include="Microsoft.Identity.Client"                     Version="4.*" />

<!-- Key Vault -->
<PackageReference Include="Azure.Security.KeyVault.Secrets"               Version="4.*" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="*" />

<!-- Managed Identity / DefaultAzureCredential -->
<PackageReference Include="Azure.Identity"                                Version="1.*" />

<!-- All Azure SDK clients accept DefaultAzureCredential -->
<PackageReference Include="Azure.Messaging.ServiceBus"                    Version="7.*" />
<PackageReference Include="Azure.Storage.Blobs"                           Version="12.*" />
<PackageReference Include="Azure.Messaging.EventGrid"                     Version="4.*" />
JWT Claim Quick Reference
csharp
// Common claim extractions in .NET 8
var userId     = user.FindFirstValue("oid")!;              // Stable user object ID
var tenantId   = user.FindFirstValue("tid")!;              // Tenant ID
var clientId   = user.FindFirstValue("azp")!;              // Calling app client ID
var scopes     = user.FindFirstValue("scp")?.Split(' ');   // Delegated scopes array
var roles      = user.FindAll("roles").Select(c => c.Value); // App roles
var name       = user.FindFirstValue("name");               // Display name
var email      = user.FindFirstValue("preferred_username"); // UPN / email
var isAppOnly  = user.FindFirstValue("idtyp") == "app";     // No user in token
var authMethod = user.FindFirstValue("amr");                // pwd, mfa, wia, etc.

// Entra ID claim type constants (avoid magic strings)
// Microsoft.Identity.Web exposes ClaimConstants:
// ClaimConstants.ObjectId     → "oid"
// ClaimConstants.TenantId     → "tid"
// ClaimConstants.Scope        → "scp"
// ClaimConstants.Roles        → "roles"
ScenarioPattern to Use
App Service reads a Key Vault secretSystem-assigned MI + Key Vault Reference in App Settings
Function App sends to Service BusSystem-assigned MI + Azure Service Bus Data Sender role
Multiple services share one identityUser-assigned MI attached to all resources
AKS pod authenticates to Key VaultWorkload Identity + federated credential
SPA authenticates usersMSAL.js + Auth Code + PKCE — no client secret
Web app calls downstream API as userAuth Code flow + On-Behalf-Of for API chain
Background daemon calls APIClient Credentials + app role assignment
CI/CD pipeline deploys Azure resourcesService Principal + federated OIDC (no secret)
API validates incoming tokens.AddJwtBearer() with authority + audience + full TVP
Rotate database password automaticallyEvent Grid SecretNearExpiry → Function → KV new version
Block login after account compromiseEnable CAE + Identity Protection + Conditional Access
Enforce MFA for admin operationsConditional Access policy on admin app role scope