Azure Auth Series — Blog 4: Managed Identity & Key Vault#
In Blogs 1–3, everything ran on localhost. The backend read secrets from a .env file sitting on disk. That works for development, but in production you don't want secrets in environment variables, config files, or source code. You want zero secrets.
In this blog, we'll deploy the RBAC app from Blog 3 to Azure — and the backend will never see a password:
- 🔑 Azure Key Vault stores the secrets (tenant ID, API client ID)
- 🤖 System-Assigned Managed Identity lets Container Apps authenticate to Key Vault without credentials
- 📦 Azure Container Apps hosts the FastAPI backend with scale-to-zero
- 🌐 Azure Static Web Apps serves the Next.js frontend (free tier)
- 🏗️ Terraform provisions all infrastructure as code
- 🚀 deploy.sh orchestrates the full build-push-deploy pipeline
The same application code runs locally (with .env) and in Azure (with Managed Identity). The only difference is one environment variable: AZURE_KEY_VAULT_URL.
The Azure Auth Series#
| Blog | Topic | What You'll Learn |
|---|---|---|
| 1. Basic Login | Frontend auth | Sign in with Microsoft Entra ID |
| 2. Protected API | Backend auth | Build a FastAPI backend that validates tokens |
| 3. RBAC | Authorization | Control access based on user roles |
| 4. Managed Identity | You are here | Deploy to Azure without storing credentials |
| 5. Multi-Tenant | Organization support | Let users from any org sign in |
| 6. Service-to-Service | OBO + Client Credentials | Authenticate services to each other |
| 7. API Gateway | APIM | Centralize auth with Azure API Management |
What We're Building#
Architecture#
┌─────────────────────────────────┐
│ Azure Static Web Apps │
│ (Next.js static export) │
│ Free tier, global CDN │
└──────────────┬──────────────────┘
│ HTTPS
▼
┌──────────────────────────────────────────────────────┐
│ Azure Container Apps │
│ ┌────────────────────────────────────────────────┐ │
│ │ FastAPI Backend │ │
│ │ │ │
│ │ config.py: │ │
│ │ if AZURE_KEY_VAULT_URL: │ │
│ │ credential = DefaultAzureCredential() │ │
│ │ client = SecretClient(vault_url, cred) │ │
│ │ TENANT_ID = client.get_secret(...) │ │
│ │ else: │ │
│ │ TENANT_ID = os.getenv(...) # local dev │ │
│ │ │ │
│ │ identity: SystemAssigned ◄── No passwords! │ │
│ └────────────────────────────┬───────────────────┘ │
│ │ │
│ Consumption tier │ MI token │
│ Scale 0→1 │ (automatic) │
└───────────────────────────────┼──────────────────────┘
▼
┌─────────────────────────────────┐
│ Azure Key Vault │
│ • azure-tenant-id │
│ • azure-api-client-id │
│ │
│ Access Policy: │
│ Container App MI → Get │
│ Deployer → Get,Set,Delete │
└─────────────────────────────────┘
What Changed from Blog 3#
| Blog 3 (RBAC) | Blog 4 (Managed Identity) |
|---|---|
| Runs on localhost only | Deployed to Azure |
Secrets in .env file | Secrets in Key Vault |
| No infrastructure code | Terraform modules (5 resources) |
npm run dev | Static export → Azure Static Web Apps |
uvicorn main:app | Docker → ACR → Container Apps |
| Hardcoded CORS origin | Configurable ALLOWED_ORIGINS |
| No health check | /health endpoint |
What Stayed the Same#
The application logic is identical to Blog 3. Same RBAC, same endpoints, same role-adaptive UI. The only code change is config.py — 15 lines that switch between .env and Key Vault.
How Managed Identity Works#
Before diving into code, let's understand the mechanism:
Traditional approach (secrets everywhere):
App
→ reads CLIENT_SECRET from env var
→ sends secret to Azure AD
→ gets token
→ calls Key Vault
Managed Identity approach (zero secrets):
App
→ calls DefaultAzureCredential()
→ Azure platform injects token automatically
→ calls Key Vault
▲
│
No password, no secret, no certificate
The platform handles authentication
When you enable System-Assigned Managed Identity on a Container App, Azure creates an identity (service principal) tied to that resource. The app can request tokens for Azure services without any credentials — the Azure platform handles the token exchange behind the scenes.
DefaultAzureCredential is the Python SDK class that makes this seamless:
- In Azure: Uses the Managed Identity automatically
- On your laptop: Falls back to
az logincredentials - In CI/CD: Can use environment variables or workload identity
One line of code, works everywhere.
Step 1: The Config Change — 15 Lines#
This is the entire code change from Blog 3. Everything else is infrastructure.
File: backend/config.py
import os import logging from dotenv import load_dotenv load_dotenv() logger = logging.getLogger(__name__) # ── Key Vault via Managed Identity (deployed) with .env fallback (local) ── KEY_VAULT_URL = os.getenv("AZURE_KEY_VAULT_URL", "") if KEY_VAULT_URL: from azure.identity import DefaultAzureCredential from azure.keyvault.secrets import SecretClient logger.info("AZURE_KEY_VAULT_URL detected — loading config from Key Vault") credential = DefaultAzureCredential() client = SecretClient(vault_url=KEY_VAULT_URL, credential=credential) TENANT_ID = client.get_secret("azure-tenant-id").value API_CLIENT_ID = client.get_secret("azure-api-client-id").value logger.info("Loaded TENANT_ID and API_CLIENT_ID from Key Vault") else: logger.info("No AZURE_KEY_VAULT_URL — loading config from .env / environment") TENANT_ID = os.getenv("AZURE_TENANT_ID", "") API_CLIENT_ID = os.getenv("AZURE_API_CLIENT_ID", "") # ── Derived values (same as Blog 3) ── AUDIENCE = f"api://{API_CLIENT_ID}" AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" JWKS_URI = f"{AUTHORITY}/discovery/v2.0/keys" ISSUER = f"https://sts.windows.net/{TENANT_ID}/" # ── CORS ── ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000")
How it works:
- Check if
AZURE_KEY_VAULT_URLis set (only in Azure, via Terraform) - If yes → use
DefaultAzureCredential+SecretClientto fetch secrets from Key Vault - If no → fall back to
.envfile (local development, same as Blog 3) - Derived values (
AUDIENCE,JWKS_URI,ISSUER) are computed the same way regardless
The imports are conditional — azure-identity and azure-keyvault-secrets are only imported when running in Azure. This means local development doesn't require these packages (though they're in requirements.txt for the Docker build).
New Dependencies#
# Added to requirements.txt
azure-identity==1.19.0
azure-keyvault-secrets==4.9.0
| Package | Purpose |
|---|---|
azure-identity | Provides DefaultAzureCredential for MI authentication |
azure-keyvault-secrets | Client for reading secrets from Key Vault |
Step 2: Minor Backend Changes#
Two small changes to main.py support deployment:
Configurable CORS#
from config import ALLOWED_ORIGINS # Configurable CORS: reads ALLOWED_ORIGINS from config (comma-separated) origins = [o.strip() for o in ALLOWED_ORIGINS.split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )
Blog 3 hardcoded http://localhost:3000. Now CORS origins come from the ALLOWED_ORIGINS environment variable, which Terraform sets to include both the Static Web App URL and localhost.
Health Check Endpoint#
@app.get("/health") async def health(): return {"status": "healthy"}
Container Apps uses this for liveness checks. It's unauthenticated — no Bearer token needed.
Step 3: Docker Image#
File: backend/Dockerfile
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Standard Python container. Nothing Azure-specific here — the Managed Identity magic happens at runtime when DefaultAzureCredential detects it's running inside a Container App.
Step 4: Frontend — Static Export#
One config change enables static export for Azure Static Web Apps:
File: frontend/next.config.mjs
const nextConfig = { output: "export", images: { unoptimized: true, remotePatterns: [ { protocol: "https", hostname: "graph.microsoft.com", }, ], }, }; export default nextConfig;
output: "export" tells Next.js to generate a static out/ directory with plain HTML/CSS/JS files. No server-side rendering needed — MSAL handles auth entirely in the browser.
SPA Routing Fallback:
File: frontend/staticwebapp.config.json
{ "navigationFallback": { "rewrite": "/index.html", "exclude": ["/_next/*", "/favicon.ico", "/*.png", "/*.svg"] } }
This tells Azure Static Web Apps to serve index.html for all routes (like /dashboard) so client-side routing works.
Step 5: Terraform Infrastructure#
The infrastructure is organized into five Terraform modules:
infra/
├── main.tf # Orchestrates all modules
├── variables.tf # Input variables
├── outputs.tf # Values for deploy.sh
└── modules/
├── resource_group/ # Azure Resource Group
├── key_vault/ # Key Vault + deployer access policy
├── container_registry/ # ACR for Docker images
├── container_apps/ # Container App + Managed Identity
└── static_web_app/ # SWA for frontend
5a. Main Orchestration#
File: infra/main.tf
terraform { required_version = ">= 1.5.0" required_providers { azurerm = { source = "hashicorp/azurerm" version = "~> 3.116.0" } } } provider "azurerm" { features { resource_group { prevent_deletion_if_contains_resources = false } key_vault { purge_soft_delete_on_destroy = true } } } data "azurerm_client_config" "current" {}
The data "azurerm_client_config" block reads the current Azure CLI login — used to set Key Vault access policies for the deployer.
5b. Key Vault Module#
File: infra/modules/key_vault/main.tf
resource "azurerm_key_vault" "this" { name = var.name resource_group_name = var.resource_group_name location = var.location tenant_id = var.tenant_id sku_name = "standard" soft_delete_retention_days = 7 purge_protection_enabled = false tags = var.tags } # Access policy: deployer (current user running Terraform / deploy.sh) resource "azurerm_key_vault_access_policy" "deployer" { key_vault_id = azurerm_key_vault.this.id tenant_id = var.tenant_id object_id = var.deployer_object_id secret_permissions = [ "Get", "List", "Set", "Delete", "Purge", ] }
The deployer (you) gets full secret permissions. This is needed because deploy.sh writes secrets to the vault.
5c. Container Apps Module — The Core#
File: infra/modules/container_apps/main.tf
resource "azurerm_container_app" "backend" { name = var.backend_app_name resource_group_name = var.resource_group_name container_app_environment_id = azurerm_container_app_environment.this.id revision_mode = "Single" # System-assigned managed identity — the teaching point of Blog 4 identity { type = "SystemAssigned" } registry { server = var.acr_server username = var.acr_username password_secret_name = "acr-password" } secret { name = "acr-password" value = var.acr_password } template { min_replicas = var.backend_min_replicas max_replicas = var.backend_max_replicas container { name = "backend" image = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" cpu = var.backend_cpu memory = var.backend_memory # Key Vault URL — triggers config.py to use Managed Identity env { name = "AZURE_KEY_VAULT_URL" value = var.key_vault_url } # CORS: allow the Static Web App frontend env { name = "ALLOWED_ORIGINS" value = var.allowed_origins } } } ingress { external_enabled = true target_port = 8000 transport = "http" traffic_weight { latest_revision = true percentage = 100 } } tags = var.tags }
Key details:
identity { type = "SystemAssigned" }— Azure creates a service principal for this containerAZURE_KEY_VAULT_URLenv var — triggersconfig.pyto useDefaultAzureCredential- Initial image is a placeholder —
deploy.shreplaces it with the real backend image min_replicas = 0— scales to zero when idle (cost savings)- External ingress on port 8000 — matches the Dockerfile's
EXPOSE
5d. Key Vault Access Policy for Managed Identity#
Back in main.tf, after the Container App is created:
# Created after Container App so we have the MI principal ID resource "azurerm_key_vault_access_policy" "container_app_mi" { key_vault_id = module.key_vault.id tenant_id = data.azurerm_client_config.current.tenant_id object_id = module.container_apps.managed_identity_principal_id secret_permissions = [ "Get", "List", ] }
Least privilege: The Container App's Managed Identity can only Get and List secrets — not Set, Delete, or Purge. It reads what it needs and nothing more.
This creates a dependency chain: Container App must exist first (to get its MI principal ID), then the access policy is created.
5e. Outputs for deploy.sh#
File: infra/outputs.tf
output "backend_url" { value = module.container_apps.backend_url } output "frontend_url" { value = "https://${module.static_web_app.default_hostname}" } output "key_vault_name" { value = module.key_vault.name } output "acr_login_server" { value = module.container_registry.login_server } output "swa_deployment_token" { value = module.static_web_app.api_key sensitive = true }
These outputs are consumed by deploy.sh to know where to push images, deploy files, and seed secrets.
Step 6: The Deployment Pipeline#
deploy.sh orchestrates the full deployment in five steps:
File: deploy.sh
./deploy.sh
│
├─ Preflight: Verify backend/.env exists, Terraform is initialized
├─ Read Terraform outputs: URLs, vault name, ACR server, SWA token
│
├─ Step 1: Seed Key Vault
│ Read backend/.env → write secrets to Key Vault via az CLI
│
├─ Step 2: Build + Push Docker Image
│ docker build → docker push to ACR
│
├─ Step 3: Update Container App
│ az containerapp update → deploy new image
│
├─ Step 4: Build Frontend
│ NEXT_PUBLIC_API_URL=$BACKEND_URL npm run build → static ./out
│
├─ Step 5: Deploy to Static Web App
│ swa-cli deploy ./out → Azure Static Web Apps
│
└─ Step 6: Add Redirect URI
Register the SWA URL as a valid redirect in Azure AD
Key Steps Explained#
Seeding Key Vault:
TENANT_ID=$(grep AZURE_TENANT_ID backend/.env | cut -d= -f2) API_CLIENT_ID=$(grep AZURE_API_CLIENT_ID backend/.env | cut -d= -f2) az keyvault secret set --vault-name "$KEY_VAULT_NAME" \ --name "azure-tenant-id" --value "$TENANT_ID" az keyvault secret set --vault-name "$KEY_VAULT_NAME" \ --name "azure-api-client-id" --value "$API_CLIENT_ID"
The secrets move from the local .env file into Key Vault. After this, the backend in Azure reads them via Managed Identity — the .env file is only needed locally.
Building and pushing the Docker image:
az acr login --name "${ACR_LOGIN_SERVER%%.*}" IMAGE_TAG="$ACR_LOGIN_SERVER/blog4-backend:latest" docker build --platform linux/amd64 -t "$IMAGE_TAG" backend/ docker push "$IMAGE_TAG"
--platform linux/amd64 is important if you're building on an M-series Mac — Container Apps runs Linux x86.
Building the frontend with production URL:
NEXT_PUBLIC_API_URL="$BACKEND_URL" npm run build
The backend URL is injected at build time. Since this is a static export, all NEXT_PUBLIC_* env vars are baked into the JavaScript bundle.
Adding the redirect URI:
URIS_JSON="[\"http://localhost:3000\",\"$FRONTEND_URL\"]" az rest --method PATCH \ --uri "https://graph.microsoft.com/v1.0/applications/$SPA_OBJ_ID" \ --body "{\"spa\":{\"redirectUris\":$URIS_JSON}}"
Azure AD only redirects to registered URIs. The script adds the deployed Static Web App URL alongside localhost.
Step 7: The Complete Flow#
Local Development (unchanged from Blog 3)#
$ uvicorn main:app --reload
config.py: No AZURE_KEY_VAULT_URL
→ reads .env file
→ TENANT_ID, API_CLIENT_ID from file
Production (Azure)#
Container App starts
│
▼
config.py: AZURE_KEY_VAULT_URL = https://kv-blog4-xyz.vault.azure.net/
│
▼
DefaultAzureCredential()
│
├─ Detects: running in Container App
├─ Requests MI token from Azure platform
├─ No password, no certificate, no env var
│
▼
SecretClient(vault_url, credential)
│
├─ client.get_secret("azure-tenant-id")
├─ client.get_secret("azure-api-client-id")
│
▼
TENANT_ID, API_CLIENT_ID loaded
│
▼
FastAPI starts normally
│
▼
JWT validation works exactly like Blog 3
Running Locally (Development)#
Local development is identical to Blog 3 — Managed Identity is only active when AZURE_KEY_VAULT_URL is set:
# 1. Setup Azure AD (creates .env files) az login ./setup.sh # 2. Start backend cd backend python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt uvicorn main:app --reload # 3. Start frontend cd frontend npm install npm run dev
Deploying to Azure#
1. Configure Terraform#
cd infra cp terraform.tfvars.example terraform.tfvars # Edit terraform.tfvars: set key_vault_name, acr_name (must be globally unique)
2. Provision Infrastructure#
terraform init terraform apply
This creates: Resource Group, Key Vault, Container Registry, Container Apps Environment, Container App (with Managed Identity), Static Web App, Log Analytics workspace, and all access policies.
3. Deploy Application#
cd .. ./deploy.sh
The script:
- Seeds Key Vault with secrets from
backend/.env - Builds and pushes the Docker image to ACR
- Updates the Container App with the new image
- Builds the frontend with the production API URL
- Deploys to Static Web Apps
- Registers the frontend URL as an Azure AD redirect URI
4. Verify#
# Health check curl $(cd infra && terraform output -raw backend_url)/health # View logs az containerapp logs show \ -n $(cd infra && terraform output -raw backend_app_name) \ -g $(cd infra && terraform output -raw resource_group_name) \ --follow
Cleanup#
./cleanup.sh
Destroys all Terraform infrastructure, deletes app registrations, removes test users, and cleans up local .env files.
Cost Estimate#
| Component | Service | SKU | Est. Cost/Month |
|---|---|---|---|
| Backend | Container Apps | Consumption | ~$2 |
| Frontend | Static Web Apps | Free | Free |
| Secrets | Key Vault | Standard | ~$0.50 |
| Docker images | Container Registry | Basic | ~$2.50 |
| Total | ~$5 |
Container Apps on the Consumption tier charges per vCPU-second and memory-second. With scale-to-zero (min_replicas = 0), you only pay when requests are being served.
Common Pitfalls#
1. "DefaultAzureCredential failed" in Container Apps#
The Managed Identity access policy hasn't been created, or Terraform apply didn't complete successfully.
Fix: Verify the access policy exists in Key Vault (Azure Portal → Key Vault → Access policies). The Container App's principal ID should have Get and List permissions.
2. Key Vault Secret Names Don't Match#
config.py calls client.get_secret("azure-tenant-id") but deploy.sh stored it under a different name.
Fix: Ensure the secret names in deploy.sh (azure-tenant-id, azure-api-client-id) exactly match what config.py requests. They use hyphens, not underscores.
3. Docker Build Fails on M-series Mac#
Container Apps runs linux/amd64. If you build without --platform, the image might be arm64.
Fix: Always use docker build --platform linux/amd64 for Azure deployments.
4. Frontend Shows "Network Error" After Deploy#
The frontend was built without the production API URL, so it's still pointing to localhost:8000.
Fix: deploy.sh injects NEXT_PUBLIC_API_URL at build time. If you rebuild manually, pass the Container Apps URL: NEXT_PUBLIC_API_URL="https://ca-blog4-backend.xxx.azurecontainerapps.io" npm run build.
5. CORS Error from Static Web App#
The Container App's ALLOWED_ORIGINS doesn't include the Static Web App URL.
Fix: Terraform sets this automatically via "https://${module.static_web_app.default_hostname},http://localhost:3000". If you changed the SWA name after deploy, update the Container App env var.
6. "Redirect URI not registered" After Deploy#
Azure AD only allows redirects to registered URIs. deploy.sh adds the SWA URL, but if the deploy was interrupted, it might not have completed.
Fix: Manually add the URL: Azure Portal → App registrations → your SPA app → Authentication → Add the Static Web App URL.
Security Model#
┌───────────────────────────────────────────────────────┐
│ Zero Secrets │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Source Code │ │ Environment │ │
│ │ No secrets │ │ Only KV URL │ │
│ │ No passwords │ │ (not secret)│ │
│ │ No certs │ │ │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Key Vault │ │
│ │ Stores: tenant ID, API client ID │ │
│ │ Access: MI (Get/List), Deployer (full) │ │
│ │ Audit: Every access logged │ │
│ │ Rotation: Change secret, app picks up new │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Managed Identity │ │
│ │ No credentials to manage or rotate │ │
│ │ Tied to the resource lifecycle │ │
│ │ Automatic token refresh │ │
│ │ Cannot be extracted or shared │ │
│ └──────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
Why this matters:
- Secrets can't leak from source code (they're not there)
- Secrets can't leak from env vars (only the vault URL is exposed, which isn't sensitive)
- Managed Identity tokens are short-lived and auto-rotated
- Key Vault provides an audit trail of every secret access
- If you need to rotate a secret, update it in Key Vault — the app picks it up on next read
What's Next#
In Blog 5: Multi-Tenant, we'll:
- Allow users from any Azure AD tenant to sign in
- Handle tenant validation — which organizations should be allowed?
- Manage per-tenant data isolation
- Update the token validation to accept multiple issuers
Managed Identity solved how the app authenticates to Azure services. Multi-tenancy solves how users from different organizations authenticate to your app.
Conclusion#
You've deployed the RBAC app to Azure with zero secrets in code:
- System-Assigned Managed Identity — the Container App authenticates to Key Vault without any credentials
- DefaultAzureCredential — one line of Python that works locally (
az login) and in Azure (MI) - Key Vault — secrets stored centrally with least-privilege access policies and audit logging
- Terraform — five modules provision the entire infrastructure reproducibly
- deploy.sh — a single script builds, pushes, deploys, and configures everything
- Static Web Apps — free hosting for the Next.js frontend with SPA routing
- Container Apps — serverless containers with scale-to-zero for the FastAPI backend
- Dual-mode config — the same
config.pyworks with.envfiles locally and Key Vault in production
The code change was 15 lines in config.py. Everything else is infrastructure. That's the point — Managed Identity doesn't change your application logic. It changes how secrets are managed, and the answer is: they aren't. Azure handles it.
Resources#
- Source Code: GitHub — azure_auth_series/04_managed_identity
- Blog 3: RBAC with Azure AD App Roles
- Microsoft — Managed Identity: What are Managed Identities?
- Microsoft — DefaultAzureCredential: Azure Identity Client Library
- Microsoft — Key Vault: About Azure Key Vault
- Terraform — azurerm: AzureRM Provider
- Microsoft — Container Apps: Azure Container Apps Overview
Happy building!