Back
View source
Cloud Engineering··20 min

Azure Auth Series — Blog 4: Managed Identity & Key Vault

Deploy the RBAC app to Azure with zero secrets in code. Use System-Assigned Managed Identity to authenticate Container Apps to Key Vault, Terraform for infrastructure, and Static Web Apps for the frontend.

Azure Auth Series — Blog 4: Managed Identity & Key Vault#

In Blogs 13, 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#

BlogTopicWhat You'll Learn
1. Basic LoginFrontend authSign in with Microsoft Entra ID
2. Protected APIBackend authBuild a FastAPI backend that validates tokens
3. RBACAuthorizationControl access based on user roles
4. Managed IdentityYou are hereDeploy to Azure without storing credentials
5. Multi-TenantOrganization supportLet users from any org sign in
6. Service-to-ServiceOBO + Client CredentialsAuthenticate services to each other
7. API GatewayAPIMCentralize 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 onlyDeployed to Azure
Secrets in .env fileSecrets in Key Vault
No infrastructure codeTerraform modules (5 resources)
npm run devStatic export → Azure Static Web Apps
uvicorn main:appDocker → ACR → Container Apps
Hardcoded CORS originConfigurable 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 login credentials
  • 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:

  1. Check if AZURE_KEY_VAULT_URL is set (only in Azure, via Terraform)
  2. If yes → use DefaultAzureCredential + SecretClient to fetch secrets from Key Vault
  3. If no → fall back to .env file (local development, same as Blog 3)
  4. Derived values (AUDIENCE, JWKS_URI, ISSUER) are computed the same way regardless

The imports are conditionalazure-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
PackagePurpose
azure-identityProvides DefaultAzureCredential for MI authentication
azure-keyvault-secretsClient 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 container
  • AZURE_KEY_VAULT_URL env var — triggers config.py to use DefaultAzureCredential
  • Initial image is a placeholder — deploy.sh replaces 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:

  1. Seeds Key Vault with secrets from backend/.env
  2. Builds and pushes the Docker image to ACR
  3. Updates the Container App with the new image
  4. Builds the frontend with the production API URL
  5. Deploys to Static Web Apps
  6. 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#

ComponentServiceSKUEst. Cost/Month
BackendContainer AppsConsumption~$2
FrontendStatic Web AppsFreeFree
SecretsKey VaultStandard~$0.50
Docker imagesContainer RegistryBasic~$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.py works with .env files 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#

Happy building!