Securing Azure Pipelines: Service Connections and Secret Management
A comprehensive guide to securing Azure DevOps pipelines with properly scoped service connections, Azure Key Vault integration, approval gates, and defense against common CI/CD attack vectors.
Securing Azure Pipelines: Service Connections and Secret Management
Overview
Azure Pipelines is a powerful CI/CD platform, but a misconfigured pipeline can leak secrets, grant excessive permissions, and expose your entire infrastructure to attack. Service connections and secret management are the two pillars that determine whether your pipeline is a secure deployment mechanism or an open door. This article covers how to properly configure service connections with least-privilege access, integrate Azure Key Vault for secret management, implement approval gates, and defend against the most common attack vectors that target CI/CD systems.
Prerequisites
- An Azure DevOps organization with at least one project
- An Azure subscription with Owner or Contributor access
- Azure CLI installed locally (
azversion 2.50+) - Node.js v18+ installed
- Basic familiarity with YAML pipeline syntax
- An Azure Key Vault instance (we will create one if needed)
Service Connection Types and When to Use Each
A service connection is a stored credential that Azure Pipelines uses to authenticate with an external service. Choosing the right type and scoping it correctly is the first line of defense.
Azure Resource Manager (ARM)
This is the most common type. It creates a service principal in Entra ID (formerly Azure AD) and grants it access to your Azure subscription or resource group.
When to use it: Any time your pipeline needs to interact with Azure resources — deploying App Services, managing AKS clusters, reading from Key Vault, or provisioning infrastructure with Terraform.
# Create a service principal scoped to a single resource group
az ad sp create-for-rbac \
--name "sp-pipeline-prod-deploy" \
--role "Contributor" \
--scopes "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-production-app" \
--years 1
# Output:
# {
# "appId": "11111111-2222-3333-4444-555555555555",
# "displayName": "sp-pipeline-prod-deploy",
# "password": "abc123~xxxxxxxxxxxxxxxxxxxxxxxxxx",
# "tenant": "99999999-8888-7777-6666-555555555555"
# }
Critical mistake people make: Using subscription-level Contributor access when they only need to deploy to one resource group. That single service principal can now modify every resource in the subscription.
Docker Registry
Used to push and pull container images. Supports Azure Container Registry (ACR), Docker Hub, and other registries.
When to use it: When your pipeline builds Docker images and pushes them to a registry, or when your deployment pulls images from a private registry.
# In your pipeline YAML
resources:
containers:
- container: build-container
image: myregistry.azurecr.io/build-tools:latest
endpoint: 'acr-connection-prod'
npm Registry
Connects to private npm feeds — either Azure Artifacts or an external npm registry.
When to use it: When your Node.js project depends on private packages hosted in Azure Artifacts or a private registry like Artifactory.
steps:
- task: npmAuthenticate@0
inputs:
workingFile: '.npmrc'
customEndpoint: 'npm-private-registry'
Generic Service Connection
A catch-all for any service that uses a URL plus credentials (username/password, token, or API key).
When to use it: Third-party services without a dedicated connector — Slack webhooks, custom APIs, Datadog, PagerDuty. Store the credentials once and reference them across pipelines.
Creating and Scoping Service Connections with Least-Privilege
The principle of least privilege is not optional — it is the foundation of pipeline security. Every service connection should have the minimum permissions required to do its job and nothing more.
Step 1: Create Separate Service Connections Per Environment
Never share a service connection between staging and production. Create one for each:
# Staging service principal — scoped to staging resource group only
az ad sp create-for-rbac \
--name "sp-pipeline-staging-deploy" \
--role "Contributor" \
--scopes "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-staging-app" \
--years 1
# Production service principal — scoped to production resource group only
az ad sp create-for-rbac \
--name "sp-pipeline-prod-deploy" \
--role "Contributor" \
--scopes "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-production-app" \
--years 1
Step 2: Use Custom Roles When Built-In Roles Are Too Broad
The built-in Contributor role can do almost anything. If your pipeline only deploys to an App Service, create a custom role:
{
"Name": "App Service Deployer",
"Description": "Can deploy code to App Service but cannot modify configuration or delete resources",
"Actions": [
"Microsoft.Web/sites/publish/Action",
"Microsoft.Web/sites/slots/publish/Action",
"Microsoft.Web/sites/restart/Action",
"Microsoft.Web/sites/slots/restart/Action",
"Microsoft.Web/sites/Read",
"Microsoft.Web/sites/config/Read",
"Microsoft.Web/sites/slots/Read"
],
"NotActions": [],
"AssignableScopes": [
"/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-production-app"
]
}
az role definition create --role-definition custom-role-deployer.json
az role assignment create \
--assignee "11111111-2222-3333-4444-555555555555" \
--role "App Service Deployer" \
--scope "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-production-app"
Step 3: Lock Down Service Connection Permissions in Azure DevOps
In Project Settings > Service Connections, configure each connection:
- Uncheck "Grant access permission to all pipelines." This is checked by default and it means any pipeline in the project can use the connection. Instead, approve specific pipelines.
- Set pipeline permissions — only the pipelines that need the connection should have access.
- Set approval checks — require a human approval before the connection can be used. Navigate to the service connection, click the three dots, select "Approvals and checks," and add an approval gate.
Azure Key Vault Integration in YAML Pipelines
Hardcoding secrets in pipeline variables is a bad practice even when they are marked as secret. Azure Key Vault is the right place to store secrets, and pipelines should pull them at runtime.
Setting Up Key Vault
# Create a Key Vault
az keyvault create \
--name "kv-myapp-prod" \
--resource-group "rg-production-app" \
--location "eastus2" \
--enable-rbac-authorization true
# Add secrets
az keyvault secret set --vault-name "kv-myapp-prod" --name "DatabaseConnectionString" --value "Server=tcp:mydb.database.windows.net;..."
az keyvault secret set --vault-name "kv-myapp-prod" --name "ApiKey" --value "sk-prod-xxxxxxxxxxxxxxxx"
az keyvault secret set --vault-name "kv-myapp-prod" --name "JwtSigningKey" --value "a-very-long-random-signing-key-here"
# Grant the pipeline service principal access to read secrets
az role assignment create \
--assignee "11111111-2222-3333-4444-555555555555" \
--role "Key Vault Secrets User" \
--scope "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-production-app/providers/Microsoft.KeyVault/vaults/kv-myapp-prod"
Note: We use Key Vault Secrets User (read-only) instead of Key Vault Administrator. The pipeline never needs to create or delete secrets.
Using the AzureKeyVault Task
steps:
- task: AzureKeyVault@2
inputs:
azureSubscription: 'arm-connection-prod'
KeyVaultName: 'kv-myapp-prod'
SecretsFilter: 'DatabaseConnectionString,ApiKey,JwtSigningKey'
RunAsPreJob: true
displayName: 'Fetch secrets from Key Vault'
- script: |
echo "Deploying with secrets loaded..."
# Secrets are available as environment variables
# $(DatabaseConnectionString), $(ApiKey), $(JwtSigningKey)
# They are automatically masked in logs
displayName: 'Deploy application'
The SecretsFilter parameter is important. Use it to fetch only the secrets you need, not *. Fetching all secrets violates least privilege and increases the blast radius if the pipeline is compromised.
Variable Groups Linked to Key Vault
Variable groups provide a reusable way to inject secrets into multiple pipelines. When linked to Key Vault, they always pull the latest secret values.
Creating a Linked Variable Group
- Go to Pipelines > Library > + Variable group
- Enable Link secrets from an Azure key vault as variables
- Select the service connection and Key Vault
- Click + Add and select the specific secrets to include
- Save the variable group
Referencing in YAML
variables:
- group: 'prod-keyvault-secrets'
stages:
- stage: Deploy
jobs:
- job: DeployApp
steps:
- script: |
npm ci --production
node deploy.js
env:
DB_CONNECTION: $(DatabaseConnectionString)
API_KEY: $(ApiKey)
displayName: 'Deploy with secrets'
Pipeline-Level Secret Variables vs Library Variable Groups
| Feature | Pipeline Secret Variables | Library Variable Groups |
|---|---|---|
| Scope | Single pipeline | Shared across pipelines |
| Source of truth | Pipeline YAML / UI | Key Vault (if linked) |
| Rotation | Manual update required | Automatic (fetched at runtime) |
| Audit trail | Limited | Full Key Vault audit logs |
| Best for | One-off pipeline secrets | Shared environment config |
My recommendation: Use library variable groups linked to Key Vault for everything except truly pipeline-specific values. The automatic rotation and audit trail alone make it worth the setup time.
Secret Rotation Strategies and Automation
Secrets that never rotate are a ticking time bomb. If a secret is compromised, the window of exposure is unlimited. Automate rotation.
Automated Rotation with Azure Automation
# Create an automation account
az automation account create \
--name "auto-secret-rotation" \
--resource-group "rg-shared-infra" \
--location "eastus2"
Rotation Runbook (PowerShell)
# rotate-api-keys.ps1
param(
[string]$KeyVaultName = "kv-myapp-prod",
[string]$SecretName = "ApiKey"
)
# Connect using managed identity
Connect-AzAccount -Identity
# Generate a new API key (this would call your API provider)
$newKey = -join ((65..90) + (97..122) + (48..57) | Get-Random -Count 64 | ForEach-Object {[char]$_})
# Update Key Vault
$secureValue = ConvertTo-SecureString $newKey -AsPlainText -Force
Set-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -SecretValue $secureValue
# Set expiration for 90 days
$expires = (Get-Date).AddDays(90)
Update-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -Expires $expires
Write-Output "Secret '$SecretName' rotated successfully. New expiration: $expires"
Key Vault Expiration Notifications
# Set an expiration date on secrets
az keyvault secret set-attributes \
--vault-name "kv-myapp-prod" \
--name "DatabaseConnectionString" \
--expires "2026-06-01T00:00:00Z"
# Configure Event Grid to fire when secrets are near expiry
az eventgrid event-subscription create \
--name "secret-expiry-alert" \
--source-resource-id "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-production-app/providers/Microsoft.KeyVault/vaults/kv-myapp-prod" \
--endpoint "https://myapp.azurewebsites.net/api/secret-expiry-webhook" \
--included-event-types "Microsoft.KeyVault.SecretNearExpiry" \
--advanced-filter data.ObjectName StringContains "DatabaseConnectionString"
Pipeline Permissions and Approval Gates for Sensitive Environments
Approval gates prevent unauthorized deployments to production. They are non-negotiable for any serious deployment pipeline.
Configuring Environments with Approvals
# In the pipeline YAML, reference an environment
stages:
- stage: DeployProduction
jobs:
- deployment: DeployProd
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying to production"
Then in Azure DevOps, configure the production environment:
- Go to Pipelines > Environments > production
- Click Approvals and checks
- Add Approvals — require sign-off from at least one person (ideally two)
- Add Business hours — restrict deployments to working hours
- Add Branch control — only allow deployments from
mainorrelease/*branches - Add Exclusive lock — prevent concurrent deployments
Example with Multiple Gates
stages:
- stage: Build
jobs:
- job: BuildAndTest
pool:
vmImage: 'ubuntu-latest'
steps:
- script: |
npm ci
npm run build
npm test
displayName: 'Build and test'
- stage: DeployStaging
dependsOn: Build
jobs:
- deployment: DeployToStaging
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- task: AzureKeyVault@2
inputs:
azureSubscription: 'arm-connection-staging'
KeyVaultName: 'kv-myapp-staging'
SecretsFilter: 'DatabaseConnectionString'
- script: |
npm ci --production
npx pm2 deploy ecosystem.config.js staging
displayName: 'Deploy to staging'
- stage: IntegrationTests
dependsOn: DeployStaging
jobs:
- job: RunIntegrationTests
steps:
- script: |
npm ci
npm run test:integration -- --environment staging
displayName: 'Run integration tests against staging'
- stage: DeployProduction
dependsOn: IntegrationTests
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployToProd
environment: 'production' # This triggers the approval gate
strategy:
runOnce:
deploy:
steps:
- task: AzureKeyVault@2
inputs:
azureSubscription: 'arm-connection-prod'
KeyVaultName: 'kv-myapp-prod'
SecretsFilter: 'DatabaseConnectionString,ApiKey,JwtSigningKey'
- script: |
npm ci --production
npx pm2 deploy ecosystem.config.js production
displayName: 'Deploy to production'
Audit Logging and Monitoring Pipeline Secret Access
If you cannot audit who accessed what secret and when, you have no security posture. Azure provides the tools; you need to enable them.
Enable Key Vault Diagnostic Logging
# Create a Log Analytics workspace
az monitor log-analytics workspace create \
--resource-group "rg-shared-infra" \
--workspace-name "law-security-audit" \
--location "eastus2"
# Enable diagnostic logs on Key Vault
az monitor diagnostic-settings create \
--name "kv-audit-logs" \
--resource "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-production-app/providers/Microsoft.KeyVault/vaults/kv-myapp-prod" \
--workspace "/subscriptions/a1b2c3d4-e5f6-7890-abcd-ef1234567890/resourceGroups/rg-shared-infra/providers/Microsoft.OperationalInsights/workspaces/law-security-audit" \
--logs '[{"category":"AuditEvent","enabled":true,"retentionPolicy":{"enabled":true,"days":365}}]'
Kusto Query for Secret Access Monitoring
// Find all secret read operations in the last 24 hours
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.KEYVAULT"
| where OperationName == "SecretGet"
| where TimeGenerated > ago(24h)
| project TimeGenerated, CallerIPAddress, identity_claim_appid_g, ResultType, requestUri_s
| order by TimeGenerated desc
// Alert on access from unknown service principals
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.KEYVAULT"
| where OperationName == "SecretGet"
| where identity_claim_appid_g !in ("11111111-2222-3333-4444-555555555555", "66666666-7777-8888-9999-aaaaaaaaaaaa")
| project TimeGenerated, CallerIPAddress, identity_claim_appid_g, requestUri_s
Azure DevOps Audit Logs
Azure DevOps also provides audit logs for service connection usage:
- Go to Organization Settings > Auditing
- Filter by category "Pipelines" and action "Service connection used"
- Export to a SIEM or Log Analytics for correlation with Key Vault access
Securing Self-Hosted Agents
Microsoft-hosted agents are ephemeral and reasonably secure. Self-hosted agents are your responsibility and they are frequently misconfigured.
Agent Hardening Checklist
# 1. Run the agent as a non-root service account
sudo useradd -m -s /bin/bash azpagent
sudo -u azpagent ./config.sh --unattended \
--url https://dev.azure.com/myorg \
--auth pat \
--token $PAT \
--pool "production-agents" \
--agent "agent-prod-01" \
--runAsService
# 2. Restrict network access — the agent should only reach:
# - dev.azure.com (Azure DevOps)
# - Your Azure resources
# - Package registries (npm, NuGet, etc.)
sudo ufw default deny outgoing
sudo ufw allow out to 13.107.6.0/24 # Azure DevOps IPs
sudo ufw allow out to 13.107.9.0/24 # Azure DevOps IPs
sudo ufw allow out to 40.82.0.0/16 # Azure services
sudo ufw enable
# 3. Clean the workspace after every build
# In your pipeline YAML:
pool:
name: 'production-agents'
steps:
- checkout: self
clean: true # Clean workspace before checkout
# ... build steps ...
- script: |
rm -rf $(Agent.TempDirectory)/*
rm -rf $(Build.ArtifactStagingDirectory)/*
condition: always()
displayName: 'Clean up sensitive files'
Agent Pool Permissions
- Create separate agent pools for production and non-production workloads.
- Grant pipeline permissions per-pool, not globally.
- Use agent pool maintenance jobs to clean stale data on a schedule.
- Enable interactive mode only for debugging — never in production.
Common Attack Vectors and How to Mitigate Them
1. Pull Request Poisoning
An attacker submits a PR that modifies the pipeline YAML to exfiltrate secrets.
Mitigation:
# azure-pipelines.yml
trigger:
branches:
include:
- main
# Do NOT use pr triggers that run the modified YAML
# Instead, use a separate pipeline for PR validation that has no access to secrets
pr:
branches:
include:
- main
# In the PR pipeline, do NOT include any AzureKeyVault tasks
# Only build and run unit tests
Additionally, in Azure DevOps, go to Pipelines > Settings and enable "Protect access to repositories in YAML pipelines" and "Limit variables that can be set at queue time."
2. Variable Injection via Queue-Time Variables
An attacker with queue permissions overrides a variable at queue time to inject a malicious value.
Mitigation:
variables:
- name: deployTarget
value: 'production-app'
readonly: true # Cannot be overridden at queue time
3. Dependency Confusion / Supply Chain Attacks
A malicious package on the public npm registry has the same name as an internal package.
Mitigation:
# .npmrc — always scope internal packages and pin the registry
@mycompany:registry=https://pkgs.dev.azure.com/myorg/_packaging/internal-feed/npm/registry/
always-auth=true
4. Secret Leakage in Build Logs
Secrets printed to stdout appear in pipeline logs, even if the variable is marked as secret.
Mitigation:
steps:
- script: |
# NEVER do this:
# echo "Connecting with key: $(ApiKey)"
# Instead, pass secrets via environment variables
node deploy.js
env:
API_KEY: $(ApiKey)
displayName: 'Deploy with masked secrets'
In your Node.js code, never log secrets:
var apiKey = process.env.API_KEY;
// WRONG
console.log("Using API key: " + apiKey);
// RIGHT
console.log("API key loaded: " + (apiKey ? "yes (" + apiKey.length + " chars)" : "MISSING"));
Complete Working Example
This is a full pipeline that demonstrates everything discussed above. It fetches secrets from Key Vault, uses scoped service connections, passes through approval gates, and includes a Node.js compliance checker.
Pipeline YAML (azure-pipelines.yml)
trigger:
branches:
include:
- main
pr: none # Separate PR pipeline without secrets
variables:
- name: nodeVersion
value: '20.x'
readonly: true
- group: 'prod-keyvault-secrets'
stages:
# ============================================
# Stage 1: Build and Test
# ============================================
- stage: Build
displayName: 'Build & Test'
pool:
vmImage: 'ubuntu-latest'
jobs:
- job: BuildJob
displayName: 'Build, Lint, Test'
steps:
- task: NodeTool@0
inputs:
versionSpec: $(nodeVersion)
displayName: 'Install Node.js'
- script: |
npm ci
npm run lint
npm test -- --coverage
displayName: 'Install, lint, test'
- task: PublishTestResults@2
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '**/test-results.xml'
condition: always()
- task: PublishCodeCoverageResults@2
inputs:
summaryFileLocation: '**/coverage/cobertura-coverage.xml'
- script: |
npm run build
tar -czf $(Build.ArtifactStagingDirectory)/app.tar.gz -C dist .
displayName: 'Build and package'
- publish: $(Build.ArtifactStagingDirectory)/app.tar.gz
artifact: app-package
displayName: 'Publish artifact'
# ============================================
# Stage 2: Security Compliance Check
# ============================================
- stage: SecurityCheck
displayName: 'Security Compliance'
dependsOn: Build
pool:
vmImage: 'ubuntu-latest'
jobs:
- job: ComplianceCheck
displayName: 'Validate Secret Rotation Compliance'
steps:
- task: NodeTool@0
inputs:
versionSpec: $(nodeVersion)
- task: AzureKeyVault@2
inputs:
azureSubscription: 'arm-connection-prod'
KeyVaultName: 'kv-myapp-prod'
SecretsFilter: '*'
RunAsPreJob: false
displayName: 'Fetch Key Vault metadata'
- script: |
npm init -y > /dev/null 2>&1
npm install @azure/identity @azure/keyvault-secrets --save > /dev/null 2>&1
node scripts/check-rotation-compliance.js
displayName: 'Run rotation compliance check'
env:
AZURE_TENANT_ID: $(tenantId)
AZURE_CLIENT_ID: $(clientId)
AZURE_CLIENT_SECRET: $(clientSecret)
KEY_VAULT_NAME: 'kv-myapp-prod'
MAX_SECRET_AGE_DAYS: '90'
# ============================================
# Stage 3: Deploy to Staging
# ============================================
- stage: DeployStaging
displayName: 'Deploy to Staging'
dependsOn: SecurityCheck
pool:
vmImage: 'ubuntu-latest'
jobs:
- deployment: StagingDeploy
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: app-package
- task: AzureKeyVault@2
inputs:
azureSubscription: 'arm-connection-staging'
KeyVaultName: 'kv-myapp-staging'
SecretsFilter: 'DatabaseConnectionString,ApiKey'
displayName: 'Fetch staging secrets'
- task: AzureWebApp@1
inputs:
azureSubscription: 'arm-connection-staging'
appType: 'webAppLinux'
appName: 'myapp-staging'
package: '$(Pipeline.Workspace)/app-package/app.tar.gz'
appSettings: |
-DB_CONNECTION "$(DatabaseConnectionString)"
-API_KEY "$(ApiKey)"
-NODE_ENV "staging"
displayName: 'Deploy to staging App Service'
# ============================================
# Stage 4: Integration Tests on Staging
# ============================================
- stage: StagingTests
displayName: 'Staging Integration Tests'
dependsOn: DeployStaging
pool:
vmImage: 'ubuntu-latest'
jobs:
- job: IntegrationTests
steps:
- script: |
npm ci
npm run test:integration -- --base-url https://myapp-staging.azurewebsites.net
displayName: 'Run integration tests against staging'
timeoutInMinutes: 10
# ============================================
# Stage 5: Deploy to Production (approval required)
# ============================================
- stage: DeployProduction
displayName: 'Deploy to Production'
dependsOn: StagingTests
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
pool:
name: 'production-agents' # Self-hosted for prod
jobs:
- deployment: ProdDeploy
environment: 'production' # Triggers approval gate
strategy:
runOnce:
preDeploy:
steps:
- script: echo "Pre-deploy checks passed. Awaiting approval..."
displayName: 'Pre-deploy validation'
deploy:
steps:
- download: current
artifact: app-package
- task: AzureKeyVault@2
inputs:
azureSubscription: 'arm-connection-prod'
KeyVaultName: 'kv-myapp-prod'
SecretsFilter: 'DatabaseConnectionString,ApiKey,JwtSigningKey'
displayName: 'Fetch production secrets'
- task: AzureWebApp@1
inputs:
azureSubscription: 'arm-connection-prod'
appType: 'webAppLinux'
appName: 'myapp-production'
package: '$(Pipeline.Workspace)/app-package/app.tar.gz'
deployToSlotOrASE: true
slotName: 'staging-slot'
appSettings: |
-DB_CONNECTION "$(DatabaseConnectionString)"
-API_KEY "$(ApiKey)"
-JWT_KEY "$(JwtSigningKey)"
-NODE_ENV "production"
displayName: 'Deploy to staging slot'
- task: AzureAppServiceManage@0
inputs:
azureSubscription: 'arm-connection-prod'
Action: 'Swap Slots'
WebAppName: 'myapp-production'
SourceSlot: 'staging-slot'
SwapWithProduction: true
displayName: 'Swap staging slot to production'
on:
success:
steps:
- script: |
echo "Production deployment completed successfully at $(date)"
echo "Build: $(Build.BuildNumber)"
echo "Commit: $(Build.SourceVersion)"
displayName: 'Log deployment success'
failure:
steps:
- script: |
echo "##vso[task.logissue type=error]Production deployment FAILED"
# Notify the team (integrate with your alerting)
curl -X POST "$(SlackWebhookUrl)" \
-H "Content-Type: application/json" \
-d "{\"text\":\"PROD DEPLOYMENT FAILED - Build $(Build.BuildNumber)\"}"
displayName: 'Alert on failure'
Secret Rotation Compliance Checker (scripts/check-rotation-compliance.js)
var KeyVaultClient = require("@azure/keyvault-secrets");
var Identity = require("@azure/identity");
var vaultName = process.env.KEY_VAULT_NAME;
var maxAgeDays = parseInt(process.env.MAX_SECRET_AGE_DAYS || "90", 10);
var vaultUrl = "https://" + vaultName + ".vault.azure.net";
function checkCompliance() {
var credential = new Identity.DefaultAzureCredential();
var client = new KeyVaultClient.SecretClient(vaultUrl, credential);
var violations = [];
var checked = 0;
var now = new Date();
console.log("===========================================");
console.log(" Secret Rotation Compliance Report");
console.log(" Vault: " + vaultName);
console.log(" Max age: " + maxAgeDays + " days");
console.log(" Date: " + now.toISOString());
console.log("===========================================\n");
return listSecrets(client, now, maxAgeDays, violations, checked);
}
function listSecrets(client, now, maxAgeDays, violations, checked) {
var iterator = client.listPropertiesOfSecrets();
return processSecrets(iterator, client, now, maxAgeDays, violations, checked);
}
function processSecrets(iterator, client, now, maxAgeDays, violations, checked) {
return iterator.next().then(function(result) {
if (result.done) {
return finalize(violations, checked);
}
var secretProperties = result.value;
checked++;
var name = secretProperties.name;
var createdOn = secretProperties.createdOn;
var updatedOn = secretProperties.updatedOn;
var expiresOn = secretProperties.expiresOn;
var enabled = secretProperties.enabled;
var lastModified = updatedOn || createdOn;
var ageDays = Math.floor((now - lastModified) / (1000 * 60 * 60 * 24));
var status = "OK";
var issues = [];
// Check age
if (ageDays > maxAgeDays) {
issues.push("Secret is " + ageDays + " days old (max: " + maxAgeDays + ")");
status = "VIOLATION";
}
// Check expiration
if (!expiresOn) {
issues.push("No expiration date set");
status = "WARNING";
} else if (expiresOn < now) {
issues.push("Secret EXPIRED on " + expiresOn.toISOString());
status = "VIOLATION";
} else {
var daysUntilExpiry = Math.floor((expiresOn - now) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry < 14) {
issues.push("Expires in " + daysUntilExpiry + " days");
status = "WARNING";
}
}
// Check enabled
if (!enabled) {
issues.push("Secret is DISABLED");
status = "WARNING";
}
// Print result
var icon = status === "OK" ? "[PASS]" : status === "WARNING" ? "[WARN]" : "[FAIL]";
console.log(icon + " " + name);
console.log(" Age: " + ageDays + " days | Last modified: " + lastModified.toISOString());
if (expiresOn) {
console.log(" Expires: " + expiresOn.toISOString());
}
if (issues.length > 0) {
issues.forEach(function(issue) {
console.log(" -> " + issue);
});
violations.push({ name: name, issues: issues, status: status });
}
console.log("");
return processSecrets(iterator, client, now, maxAgeDays, violations, checked);
});
}
function finalize(violations, checked) {
console.log("===========================================");
console.log(" Results: " + checked + " secrets checked");
console.log(" Violations: " + violations.filter(function(v) { return v.status === "VIOLATION"; }).length);
console.log(" Warnings: " + violations.filter(function(v) { return v.status === "WARNING"; }).length);
console.log("===========================================\n");
var hardFailures = violations.filter(function(v) { return v.status === "VIOLATION"; });
if (hardFailures.length > 0) {
console.error("COMPLIANCE CHECK FAILED");
console.error("The following secrets must be rotated immediately:");
hardFailures.forEach(function(v) {
console.error(" - " + v.name + ": " + v.issues.join(", "));
});
process.exit(1);
}
console.log("COMPLIANCE CHECK PASSED");
return 0;
}
checkCompliance().catch(function(err) {
console.error("Compliance check error: " + err.message);
process.exit(1);
});
Sample output (approximately 1.2s runtime):
===========================================
Secret Rotation Compliance Report
Vault: kv-myapp-prod
Max age: 90 days
Date: 2026-02-08T14:32:01.000Z
===========================================
[PASS] DatabaseConnectionString
Age: 23 days | Last modified: 2026-01-16T09:00:00.000Z
Expires: 2026-04-16T09:00:00.000Z
[FAIL] ApiKey
Age: 147 days | Last modified: 2025-09-14T12:00:00.000Z
-> Secret is 147 days old (max: 90)
-> No expiration date set
[WARN] JwtSigningKey
Age: 45 days | Last modified: 2025-12-25T08:00:00.000Z
-> Expires in 10 days
===========================================
Results: 3 secrets checked
Violations: 1
Warnings: 1
===========================================
COMPLIANCE CHECK FAILED
The following secrets must be rotated immediately:
- ApiKey: Secret is 147 days old (max: 90), No expiration date set
Common Issues and Troubleshooting
1. Service Connection Authorization Failure
Error:
##[error]Failed to obtain the Json Web Token(JWT) using service principal client ID.
Error: AADSTS7000215: Invalid client secret is provided.
Cause: The service principal secret has expired. Azure creates secrets with a default expiry of 1 year (or 2 years depending on how it was created).
Fix:
# Check when the credentials expire
az ad app credential list --id "11111111-2222-3333-4444-555555555555" --query "[].{endDateTime:endDateTime}"
# Rotate the credentials
az ad app credential reset --id "11111111-2222-3333-4444-555555555555" --years 1
# Update the service connection in Azure DevOps with the new secret
2. Key Vault Access Denied
Error:
##[error]Get secrets failed. Error: Access denied. Caller was not found on any access policy.
Status code: 403
Cause: The service principal does not have the right role assignment on the Key Vault. This commonly happens when the Key Vault uses RBAC authorization but the role assignment was made on the wrong scope, or when it uses access policies and the service principal was not added.
Fix:
# For RBAC-enabled vaults
az role assignment create \
--assignee "11111111-2222-3333-4444-555555555555" \
--role "Key Vault Secrets User" \
--scope "/subscriptions/.../resourceGroups/.../providers/Microsoft.KeyVault/vaults/kv-myapp-prod"
# For access-policy-based vaults
az keyvault set-policy \
--name "kv-myapp-prod" \
--spn "11111111-2222-3333-4444-555555555555" \
--secret-permissions get list
3. Variable Group Secrets Not Available in Pipeline
Error:
##[warning]Variable 'DatabaseConnectionString' was not found in the linked variable group.
The variable will be empty.
Cause: The secret name in Key Vault contains characters that are not valid as pipeline variable names (e.g., dots or slashes), or the secret was not explicitly added to the variable group's authorized list.
Fix: Key Vault secret names can contain hyphens, but Azure Pipelines converts hyphens to underscores when mapping to variables. A secret named database-connection-string becomes $(database_connection_string). If the names do not match, the variable will be empty. Go to the variable group and re-authorize the secrets.
4. Self-Hosted Agent Cannot Access Key Vault
Error:
##[error]Unable to connect to Azure Key Vault 'kv-myapp-prod'.
Error: getaddrinfo ENOTFOUND kv-myapp-prod.vault.azure.net
Cause: The self-hosted agent cannot resolve the Key Vault DNS name. This happens when the Key Vault has a private endpoint enabled and the agent is not in the same virtual network, or when the agent's DNS is not configured to resolve private endpoints.
Fix:
# Verify DNS resolution from the agent
nslookup kv-myapp-prod.vault.azure.net
# If using private endpoints, ensure the agent VM is in a VNet
# that is peered with the Key Vault's VNet, and that a Private DNS Zone
# is linked to the agent's VNet
az network private-dns link vnet create \
--resource-group "rg-shared-infra" \
--zone-name "privatelink.vaultcore.azure.net" \
--name "agent-vnet-link" \
--virtual-network "vnet-agents" \
--registration-enabled false
5. Pipeline Hangs Waiting for Approval That Nobody Knows About
Error: No error — the pipeline just sits at "Waiting for approval" indefinitely.
Cause: The approval gate was configured but the approvers were not notified, or the notification settings were not configured in Azure DevOps.
Fix:
- Go to Project Settings > Notifications and ensure pipeline approval notifications are enabled.
- Set a timeout on the approval check (e.g., 48 hours) so the pipeline fails instead of hanging forever.
- Consider integrating approval notifications with Slack or Microsoft Teams via service hooks.
Best Practices
Never store secrets in pipeline YAML or repository files. Use Azure Key Vault with linked variable groups. Even "secret" pipeline variables are stored in Azure DevOps and are recoverable by organization admins.
Create one service connection per environment per service. A staging deployment should never use a production service connection. Separate blast radius.
Set expiration dates on every secret in Key Vault. Use the Event Grid integration to alert before expiration. Automate rotation with Azure Automation or Azure Functions.
Disable "Grant access permission to all pipelines" on every service connection. This is the single most impactful security setting you can change today. Go check your service connections right now.
Use Workload Identity federation instead of client secrets for service connections. This eliminates the need to manage and rotate service principal credentials entirely. Azure DevOps supports this natively for ARM service connections.
Run a separate pipeline for pull request validation that has zero access to secrets. PR pipelines should only build and run unit tests. A malicious PR should not be able to access any credentials.
Audit Key Vault access logs regularly. Set up alerts for access from unknown service principals or unexpected IP addresses. Correlate with Azure DevOps audit logs for a complete picture.
Use deployment slots and swap operations for zero-downtime deploys. This is not strictly a security practice, but it means your rollback does not require re-fetching and re-injecting secrets — the previous slot still has them.
Pin your pipeline agent image versions. If you use
vmImage: 'ubuntu-latest', your build environment changes without your knowledge. UsevmImage: 'ubuntu-22.04'to ensure reproducibility and prevent supply chain attacks via agent image manipulation.Implement branch protection on your pipeline YAML file. Require code review for any changes to
azure-pipelines.yml. A single character change in the pipeline can redirect secrets to an attacker-controlled endpoint.
