No More Secret Rotas: Azure DevOps to Azure with Workload Identity Federation
Series goal (reminder): Stand up a practical, multi‑environment Terraform platform on Azure DevOps (with split pipelines for Infra/Entra/MS Graph), using secure auth, remote state, and reusable modules—scaling from Dev to Prod.
Day 3 — No More Secret Rotas: Workload Identity Federation vs App Registration
What You’ll Build Today
- WIF Deep Dive: Understand why we struggled with WIF in Day 1 and how we fixed it
- App Registration Fallback: Create an App Registration with Terraform-managed secret rotation
- 30-Day Rotation Strategy: Implement automatic monthly secret refresh using Terraform
- Comparison Framework: When to use WIF vs App Registration in real organisations
- Production Patterns: Extend this to service accounts and other App Registrations
Days 1-2 Recap: We got WIF working after troubleshooting Terraform task versions, created remote state with locking, and set up Key Vault integration. Now let’s understand the “why” behind our authentication choices.
Why Authentication Strategy Matters
In Day 1, we hit a major roadblock: WIF authentication failures that took hours to troubleshoot. The root cause? Using the wrong Terraform task version and authentication parameters. This experience highlights why understanding authentication methods is crucial.
The Problem with Secrets:
- 🔑 Manual rotation - someone always forgets
- ⏰ Pipeline failures at 3 AM when secrets expire
- 🔒 Security risks from long-lived credentials
- 📋 Compliance overhead for audit trails
WIF Solution:
- ✅ No secrets to rotate or manage
- ✅ Short-lived tokens (typically 1 hour)
- ✅ Automatic renewal by Azure DevOps
- ✅ Recommended by Microsoft for new projects
Step 1 — WIF: What Went Wrong in Day 1 and Why It Matters
Remember this error from Day 1?
This took 4 hours to troubleshoot!
ERROR: Authentication failed using OIDC...
The Root Cause: Terraform Task Version Mismatch In Day 1, we discovered that TerraformTask@5 with specific parameters was essential:
Wrong approach (what failed initially):
- task: TerraformTask@4 # ← Wrong version
inputs:
backendAzureRmUseEntraIdForAuthentication: true # ← Misleading parameter
Correct approach (what worked):
- task: TerraformTask@5 # ← Must be v5
inputs:
backendAzureRmUseEntraIdForAuthentication: false # ← Counter-intuitive!
backendAzureRmUseCliFlagsForAuthentication: true # ← This enables OIDC
Why This Configuration Works The backendAzureRmUseCliFlagsForAuthentication: true parameter tells the Terraform task to use the Azure CLI authentication context, which automatically picks up the OIDC token from Azure DevOps. This is the magic that makes WIF work.
WIF Architecture Recap
Azure DevOps Pipeline
↓ (OIDC Token)
Entra ID (Azure AD)
↓ (Validates Federation)
Azure Resources (ARM)
No secrets in the chain - just temporary tokens that Azure DevOps manages automatically.
Step 2 — App Registration Fallback: For When WIF Isn’t Possible
While WIF is ideal, some organisations can’t use it due to:
Legacy compliance requirements
Third-party tool limitations
Network security policies
Existing investment in secret management
For these scenarios, let’s build a robust App Registration with Terraform-managed secret rotation that automatically refreshes every 30 days.
2.1 Create the App Registration Module
Create /codebase/modules/azure/app-registration/main.tf
# App Registration for Terraform pipeline (fallback option)
resource "azurerm_application" "terraform" {
display_name = "app-terraform-${var.environment}-uks"
owners = [data.azurerm_client_config.current.object_id]
prevent_duplicate_names = true
tags = {
environment = var.environment
managed-by = "terraform"
purpose = "terraform-pipeline"
}
}
resource "azurerm_service_principal" "terraform" {
application_id = azurerm_application.terraform.application_id
owners = [data.azurerm_client_config.current.object_id]
tags = {
environment = var.environment
managed-by = "terraform"
}
}
# Client secret with 180-day maximum lifespan but 30-day rotation
resource "azurerm_application_password" "terraform" {
application_id = azurerm_application.terraform.id
display_name = "secret-${var.rotation_trigger}"
# 180-day maximum lifespan (Azure limit)
end_date_relative = "4320h" # 180 days
# Force rotation when the trigger changes
rotate_when_changed = {
rotation = var.rotation_trigger
}
}
# RBAC Assignment for Azure resources
resource "azurerm_role_assignment" "contributor" {
scope = var.subscription_id
role_definition_name = "Contributor"
principal_id = azurerm_service_principal.terraform.object_id
}
# Storage permissions for state management
resource "azurerm_role_assignment" "storage_contributor" {
scope = var.state_storage_account_id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_service_principal.terraform.object_id
}
/codebase/modules/azure/app-registration/variables.tf
variable "environment" { type = string }
variable "subscription_id" { type = string }
variable "state_storage_account_id" { type = string }
variable "rotation_trigger" {
type = string
default = "initial"
}
2.2 Monthly Rotation Trigger Strategy Add to /codebase/env/dev/main.tf
# Monthly rotation trigger - changes value each month
locals {
# This creates a new value each month, forcing secret rotation
# Format: YYYY-MM (changes monthly)
monthly_rotation_trigger = formatdate("YYYY-MM", timestamp())
# Alternative: Force rotation on specific schedule
# monthly_rotation_trigger = "rotation-${formatdate("YYYY-MM", timestamp())}"
}
# Update your core infra module call
module "core_infra" {
source = "../../modules/azure/core-infra"
environment = "dev"
subscription_id = var.subscription_id
state_rg_name = "rg-tfstate-core-uks"
state_sa_name = "sttfstate9683"
state_container = "tfstate"
pipeline_principal_id = var.pipeline_principal_id
}
# App Registration with monthly rotation
module "app_registration" {
source = "../../modules/azure/app-registration"
environment = "dev"
subscription_id = var.subscription_id
state_storage_account_id = module.core_infra.state_storage_account_id
rotation_trigger = local.monthly_rotation_trigger
}
2.3 Store Secrets in Key Vault with Compliance Tracking Add to /codebase/modules/azure/core-infra/main.tf
# Store App Registration secrets in Key Vault
resource "azurerm_key_vault_secret" "terraform_client_id" {
name = "terraform-appreg-client-id"
value = module.app_registration.client_id
key_vault_id = azurerm_key_vault.secrets.id
tags = {
secret-type = "app-registration"
environment = var.environment
managed-by = "terraform"
}
}
resource "azurerm_key_vault_secret" "terraform_client_secret" {
name = "terraform-appreg-client-secret"
value = module.app_registration.client_secret
key_vault_id = azurerm_key_vault.secrets.id
# Set 30-day expiration for compliance tracking
expiration_date = timeadd(timestamp(), "720h") # 30 days
tags = {
secret-type = "client-secret"
environment = var.environment
rotation = "monthly-terraform"
last_rotated = timestamp()
managed-by = "terraform"
}
}
resource "azurerm_key_vault_secret" "terraform_tenant_id" {
name = "terraform-appreg-tenant-id"
value = data.azurerm_client_config.current.tenant_id
key_vault_id = azurerm_key_vault.secrets.id
tags = {
secret-type = "tenant-id"
environment = var.environment
managed-by = "terraform"
}
}
Add outputs to App Registration module (/codebase/modules/azure/app-registration/outputs.tf):
output "client_id" {
value = azurerm_application.terraform.application_id
sensitive = true
}
output "client_secret" {
value = azurerm_application_password.terraform.value
sensitive = true
}
output "application_object_id" {
value = azurerm_application.terraform.object_id
}
How the 30-Day Rotation Works The Rotation Magic Monthly Trigger Change:
monthly_rotation_trigger changes from “2024-01” to “2024-02” each month
This forces Terraform to create a new App Registration secret
Terraform State Management:
Terraform detects the rotate_when_changed trigger has updated
Creates a new App Registration password
Automatically updates Key Vault with the new secret
Pipeline Integration:
Next pipeline run uses the updated secret from Key Vault
Zero manual intervention required
Security Benefits 180-day maximum lifespan: Meets Azure’s security requirements
30-day active rotation: Better than typical 90-day policies
Automatic compliance: Key Vault tracks expiration dates
No secret sprawl: Old secrets are automatically managed
Production Example
# In production, you might want more control
variable "force_rotation" {
type = string
default = "2024-Q1" # Change quarterly for less frequent rotation
}
# Or use a random value that changes monthly
resource "random_id" "rotation" {
keepers = {
rotation = formatdate("YYYY-MM", timestamp())
}
byte_length = 8
}
Step 3 — Comparison: WIF vs App Registration Technical Comparison Aspect Workload Identity Federation App Registration + Rotation Setup Complexity Moderate (Day 1 struggles) Simple Long-term Maintenance Zero effort Automated via Terraform Security No long-lived secrets 30-day rotation Pipeline Impact Failed on wrong task version Seamless rotation Compliance Modern, recommended Traditional with automation Organizational Fit Choose WIF when:
Starting new projects
Security team prefers token-based auth
You can invest in initial setup
Using modern Azure DevOps
Choose App Registration when:
Integrating with legacy systems
Compliance requires secret rotation tracking
Third-party tools don’t support OIDC
You need immediate simplicity
Our Recommendation For this series: We’ll continue with WIF as it’s the modern approach and we’ve overcome the initial hurdles.
For your organization: Evaluate both options. Many enterprises run a hybrid approach - WIF for new projects, App Registration for legacy systems.
Applying This Pattern Elsewhere The Terraform-managed rotation pattern we built isn’t just for Terraform service accounts. You can extend it to:
4.1 Service Accounts with Password Rotation
# Rotate service account passwords monthly
resource "azuread_user" "service_account" {
user_principal_name = "svc-terraform@yourdomain.com"
display_name = "Terraform Service Account"
password = random_password.service_account.result
}
resource "random_password" "service_account" {
length = 32
special = true
keepers = {
rotation = formatdate("YYYY-MM", timestamp())
}
}
4.2 Multiple App Registrations using a module
# Generic pattern for any App Registration
module "api_app_reg" {
source = "../app-registration"
app_name = "app-api-${var.environment}"
environment = var.environment
rotation_trigger = local.monthly_rotation_trigger
}
What’s Next (Day 4) Repo Structure Deep Dive: Organising your codebase for scale
CAF Naming Conventions: Implementing consistent naming across environments
Environment Strategy: Dev, Test, Prod patterns that work
Tagging Standards: Cost management and operational visibility