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:
How They Fit Together
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 Logic02Managed 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.
| Feature | System-Assigned | User-Assigned |
|---|---|---|
| Lifecycle | Tied to the Azure resource — deleted with it | Independent — survives resource deletion |
| Shared across | One resource only | Multiple resources can share one identity |
| Creation | Enabled on the resource itself | Created separately, then assigned |
| Use case | Single-service identity, simplest setup | Shared identity, pre-provisioned credentials |
| Role assignments | Per resource | Assign once, attach to many resources |
| Entra Object ID | Auto-generated | Specified 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
# ── 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
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.
# 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// 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 Service | Role to Assign | Scope |
|---|---|---|
| Azure Key Vault (read secrets) | Key Vault Secrets User | Vault or specific secret |
| Azure Key Vault (manage secrets) | Key Vault Secrets Officer | Vault |
| Azure Service Bus (send) | Azure Service Bus Data Sender | Namespace or queue/topic |
| Azure Service Bus (receive) | Azure Service Bus Data Receiver | Namespace or queue/topic |
| Azure Blob Storage (read) | Storage Blob Data Reader | Account, container, or blob |
| Azure Blob Storage (write) | Storage Blob Data Contributor | Account or container |
| Azure SQL Database | db_datareader / db_datawriter (via SQL GRANT) | Database user mapped to MI |
| Azure Event Hubs (produce) | Azure Event Hubs Data Sender | Namespace or hub |
| Azure Event Hubs (consume) | Azure Event Hubs Data Receiver | Namespace or hub |
| Azure Event Grid (publish) | EventGrid Data Sender | Topic |
| Azure Container Registry (pull) | AcrPull | Registry |
| Azure API Management | API Management Service Reader | APIM instance |
Key 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)
# 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# 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-sa03Key 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 Store | Key Vault Object Type | Notes |
|---|---|---|
| Database connection strings | Secret | Use Key Vault Reference in App Settings |
| Third-party API keys | Secret | Set expiry + rotation policy |
| OAuth client secrets | Secret | Rotate on schedule — Key Vault tracks versions |
| JWT signing keys (symmetric) | Secret | Or use Key type for RSA signing |
| JWT signing keys (asymmetric) | Key | RSA 2048/4096 — sign inside KV, key never leaves |
| TLS certificates | Certificate | Auto-renew with DigiCert / GlobalSign |
| Storage account keys | Secret | Prefer Managed Identity instead where possible |
| Encryption keys (DEK wrap) | Key | Envelope 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)
# 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
// 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
// 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.
// 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.
| Term | Definition |
|---|---|
| Resource Owner | The user (or system) that owns the data |
| Client | The application requesting access (your web app, SPA, mobile app, daemon) |
| Authorization Server | Entra ID — issues tokens after authenticating the user/client |
| Resource Server | Your API — validates tokens and serves protected data |
| Access Token | Short-lived credential proving authorization — sent in Authorization header |
| Refresh Token | Long-lived token to obtain new access tokens without re-authentication |
| ID Token | OIDC token containing user identity claims — for the client, not the API |
| Scope | Requested permission — e.g. openid, profile, email, api://myapi/Orders.Read |
| Authorization Code | Short-lived one-time code exchanged for tokens at token endpoint |
| Client Credentials | App-only flow — no user. Service-to-service authentication. |
| PKCE | Proof Key for Code Exchange — prevents auth code interception in public clients |
Token Endpoint Calls
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-configuration04aGrant 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.
| Flow | Use Case | Has User? | Refresh Token? |
|---|---|---|---|
| Authorization Code + PKCE | Web apps, SPAs, mobile apps | ✓ Yes | ✓ Yes |
| Client Credentials | Daemons, microservices, CI/CD pipelines | ✗ No | ✗ No |
| Device Code | CLI 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)
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)
// .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)
// 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.
// 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",
},
});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
// ── 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/keys05aStandard & 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.
| Claim | Type | Description | Use For |
|---|---|---|---|
| iss | Standard | Token issuer — Entra ID URL with tenant | Validate issuer in API |
| sub | Standard | Subject — unique user/app identifier | User identity key (prefer oid for Entra) |
| aud | Standard | Audience — intended recipient(s) | MUST match your API's App ID URI |
| exp | Standard | Expiry timestamp (Unix) | Reject expired tokens |
| iat | Standard | Issued-at timestamp | Token age checks |
| nbf | Standard | Not-before timestamp | Token not valid before this time |
| oid | Entra ID | Unique user object ID across apps | Stable user identifier — use as primary key |
| tid | Entra ID | Tenant ID | Multi-tenant: validate expected tenant |
| scp | Entra ID | Space-separated delegated scopes | Check user granted required permission |
| roles | Entra ID | App roles array | Check user/app has required role |
| azp | Entra ID | Client app ID that requested token | Validate calling app in S2S flows |
| acr/amr | Entra ID | Auth context / methods (MFA, pwd, cert) | Step-up auth enforcement |
| ctry | Custom opt. | User's country (from directory) | Geo-based access control |
| groups | Entra ID opt. | Group membership GUIDs | Group-based RBAC (overage possible) |
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
// 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
// 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)
// 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 Type | Lifetime (Entra ID Default) | Storage Guidance |
|---|---|---|
| Access Token | 1 hour | Memory only — never localStorage, never cookie without HttpOnly+Secure+SameSite=Strict |
| Refresh Token | 24 hours (sliding) | HttpOnly Secure cookie (web app server-side) or secure storage (mobile) |
| ID Token | 1 hour | Memory only — only used for user info, never sent to APIs |
| Session Cookie | Configurable | Set by Entra ID — controls SSO session length |
// 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();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.
| Concept | Definition |
|---|---|
| Tenant | An Entra ID organisation. Your company's directory. Identified by a GUID tenant ID. |
| App Registration | Declares your application to Entra ID. Defines redirect URIs, scopes, roles, and required permissions. |
| Service Principal | The runtime identity of an app registration within a specific tenant. |
| Managed Identity | A special service principal managed by Azure — no credential management needed. |
| Enterprise App | A service principal with additional configuration: SSO settings, user assignments, provisioning. |
| Scope | A permission an application can request on behalf of a user (delegated). |
| App Role | A permission assigned to a user/group or app (app-to-app). Present as the roles claim in tokens. |
| Conditional Access | Policies enforced at login: require MFA, block risky sign-ins, restrict to managed devices. |
| PIM | Privileged 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.
| Registration | Type | Purpose |
|---|---|---|
| Orders API | API (Resource) | Defines scopes and app roles. Validates incoming tokens. |
| Payments API | API (Resource) | Defines its own scopes. Only accessible from authorized clients. |
| Web App (frontend) | Confidential client | Auth Code + PKCE flow. Has client secret. Requests scopes from APIs. |
| SPA (React/Vue) | Public client | Auth Code + PKCE, no secret. Requests delegated scopes. |
| CLI / DevOps tool | Public client | Device Code flow or Client Credentials. |
| Background daemon | Confidential client | Client Credentials flow. Has app role assignments. |
Create App Registration via CLI
# ── 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
{
"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 Type | Claim in Token | Granted By | Use When |
|---|---|---|---|
| Delegated Scope | scp | User consent or admin consent | User is present — app acts on user's behalf |
| App Role (User) | roles | Admin assigns role to user/group | Role-based access for users |
| App Role (App) | roles | Admin grants app permission | App-to-app, service accounts, daemons |
| Admin-only Scope | scp | Tenant admin consent only | Sensitive data — user consent not enough |
Checking Claims in Minimal API
// 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).
| Feature | Entra ID (workforce) | Entra External ID (customers) |
|---|---|---|
| Primary users | Employees, partners | Consumers, external customers |
| Social logins | Limited (via federation) | Google, Facebook, Apple, GitHub built-in |
| Local accounts | Org accounts only | Email+password, phone |
| Custom UI | Branded login page | Full custom HTML/CSS/JS via page templates |
| User flows | N/A | Sign-up/sign-in, password reset, profile edit |
| Custom policies (IEF) | N/A | Complex multi-step journeys, claims transforms |
| Pricing | Per active user | Per MAU (monthly active user) |
| Token issuer | login.microsoftonline.com/<tenant> | <tenant>.b2clogin.com/<tenant>/<policy>/v2.0 |
Validate B2C Token in .NET 8 API
// 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 Event | Triggered When | API Behavior |
|---|---|---|
| User account disabled | Admin disables user in Entra ID | API rejects token within ~15 min |
| Password change | User or admin resets password | All existing tokens invalidated |
| Session revoked | Admin revokes all user sessions | Token rejected on next API call |
| Location policy change | Conditional Access location updated | Token rejected if user outside allowed location |
| Risk level elevated | Identity Protection detects anomaly | Token rejected if risk > policy threshold |
// 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:
Zero Trust Checklist
| Control | Implementation | Status |
|---|---|---|
| No hardcoded credentials | Key Vault References in App Settings | ✅ Required |
| No connection strings in code | Managed Identity for all Azure services | ✅ Required |
| JWT validated on every call | .AddJwtBearer() with full parameter validation | ✅ Required |
| Audience validated | options.Audience set — prevents confused deputy | ✅ Required |
| Least-privilege RBAC | Scope role assignments to specific resources | ✅ Required |
| Private endpoints | Key Vault, Service Bus, Storage — no public access | ✅ Recommended |
| Managed Identity for all SAs | Replace all connection strings with MI | ✅ Recommended |
| Soft delete + purge protect | Key Vault — enabled on all production vaults | ✅ Recommended |
| Audit logs enabled | Key Vault diagnostics → Log Analytics | ✅ Required |
| CAE enabled | Declare xms_cc claim support in APIs | ✅ Recommended |
| Conditional Access policies | MFA required, block legacy auth, device compliance | ✅ Recommended |
| PIM for admin roles | Just-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)
// 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)
// 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
| Flow | Client Type | User Interaction | Token Types | Recommended? |
|---|---|---|---|---|
| Auth Code + PKCE | Public or confidential | Browser redirect | access + id + refresh | ✅ Yes — for all interactive |
| Client Credentials | Confidential only | None (machine) | access only | ✅ Yes — for daemons/S2S |
| Device Code | Public | Device + secondary browser | access + id + refresh | ✅ Yes — for CLI/IoT |
| On-Behalf-Of | Confidential only | Via inbound user token | access + refresh | ✅ Yes — for API chains |
| Implicit | Public | Browser redirect | access + id (in URL) | ❌ Deprecated |
| ROPC | Confidential | Username/password form | access + refresh | ❌ Avoid entirely |
Token Storage — Security Comparison
| Storage Location | XSS Risk | CSRF Risk | Notes |
|---|---|---|---|
| JS Memory (variable) | Low — cleared on tab close | None | ✅ Best for SPAs — MSAL default |
| sessionStorage | Medium — accessible to same tab JS | None | ✅ Acceptable for SPAs — MSAL fallback |
| localStorage | High — persists across tabs/sessions | None | ❌ Never use for tokens |
| HttpOnly Cookie | None — not accessible to JS | Medium — use SameSite=Strict | ✅ Best for server-side web apps |
| Regular Cookie | None | High | ❌ Never use — vulnerable to CSRF |
| Server-side session + Redis | None | Low (session ID in HttpOnly cookie) | ✅ Best for confidential clients |
Managed Identity vs Service Principal vs Shared Key
| Approach | Credential Rotation | Secret Storage | Audit | Recommended |
|---|---|---|---|---|
| Managed Identity (System) | Automatic — platform managed | None needed | Entra ID sign-in logs | ✅ First choice |
| Managed Identity (User) | Automatic — platform managed | None needed | Entra ID sign-in logs | ✅ When sharing across resources |
| Service Principal + Secret | Manual or automated | Key Vault | Entra ID sign-in logs | ✅ CI/CD, cross-tenant |
| Service Principal + Cert | Certificate expiry managed | Key Vault | Entra ID sign-in logs | ✅ Higher assurance than secret |
| Connection String / SAS | Manual | Key Vault minimum | Storage/SB logs | ⚠️ Legacy — migrate away |
| Shared Account Key | Manual | Key Vault minimum | Storage 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.
# 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<!-- 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.*" />// 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"| Scenario | Pattern to Use |
|---|---|
| App Service reads a Key Vault secret | System-assigned MI + Key Vault Reference in App Settings |
| Function App sends to Service Bus | System-assigned MI + Azure Service Bus Data Sender role |
| Multiple services share one identity | User-assigned MI attached to all resources |
| AKS pod authenticates to Key Vault | Workload Identity + federated credential |
| SPA authenticates users | MSAL.js + Auth Code + PKCE — no client secret |
| Web app calls downstream API as user | Auth Code flow + On-Behalf-Of for API chain |
| Background daemon calls API | Client Credentials + app role assignment |
| CI/CD pipeline deploys Azure resources | Service Principal + federated OIDC (no secret) |
| API validates incoming tokens | .AddJwtBearer() with authority + audience + full TVP |
| Rotate database password automatically | Event Grid SecretNearExpiry → Function → KV new version |
| Block login after account compromise | Enable CAE + Identity Protection + Conditional Access |
| Enforce MFA for admin operations | Conditional Access policy on admin app role scope |