Azure Key Vault Integration with Azure DevOps
Complete guide to integrating Azure Key Vault with Azure DevOps for centralized secrets management, covering variable groups, pipeline tasks, access policies, RBAC, and secret rotation strategies.
Azure Key Vault Integration with Azure DevOps
Overview
Azure Key Vault is where your secrets belong — not in pipeline variables, not in config files, not in anyone's notepad. Integrating Key Vault with Azure DevOps gives you centralized secrets management with audit logging, access policies, automatic rotation, and the ability to grant or revoke pipeline access to secrets without touching the pipeline YAML. I have migrated every team I have worked with away from plain pipeline variables to Key Vault because the security posture improvement is immediate and the integration effort is minimal.
Prerequisites
- An Azure subscription with permissions to create and manage Key Vault resources
- An Azure DevOps organization with at least one project and pipeline
- An Azure service connection configured in Azure DevOps (Azure Resource Manager type)
- Azure CLI installed locally for Key Vault setup
- Node.js 16 or later for application code examples
- Basic familiarity with Azure DevOps YAML pipelines and Azure RBAC
Setting Up Azure Key Vault for Pipeline Secrets
Start by creating a Key Vault and populating it with secrets your pipelines need.
# Create a resource group
az group create --name rg-devops-secrets --location eastus
# Create the Key Vault
az keyvault create \
--name kv-devops-pipeline \
--resource-group rg-devops-secrets \
--location eastus \
--sku standard \
--enable-rbac-authorization true
# Add secrets
az keyvault secret set \
--vault-name kv-devops-pipeline \
--name "DatabaseConnectionString" \
--value "Server=prod-db.postgres.database.azure.com;Database=appdb;User Id=appuser;Password=P@ssw0rd!2026;"
az keyvault secret set \
--vault-name kv-devops-pipeline \
--name "ApiKey" \
--value "sk-prod-a1b2c3d4e5f6g7h8i9j0"
az keyvault secret set \
--vault-name kv-devops-pipeline \
--name "JwtSigningKey" \
--value "super-secret-jwt-key-that-should-never-be-in-source-control"
# Verify
az keyvault secret list --vault-name kv-devops-pipeline --output table
Output:
Name ContentType Enabled Expires
------------------------- ------------- --------- ---------
DatabaseConnectionString true
ApiKey true
JwtSigningKey true
Naming Conventions
Key Vault secret names only allow alphanumeric characters and hyphens. Choose a convention and stick with it:
# Good: clear, consistent, environment-prefixed
prod-database-connection-string
prod-api-key
staging-database-connection-string
# Bad: inconsistent, hard to filter
DB_CONN
myApiKey
production.jwt.secret
When secrets are pulled into pipeline variables, hyphens get converted to underscores. So prod-database-connection-string becomes $(prod-database-connection-string) in YAML but maps to the environment variable PROD_DATABASE_CONNECTION_STRING.
Key Vault Access Policies vs RBAC
Azure Key Vault supports two authorization models. Pick RBAC unless you have a specific reason not to.
Vault Access Policies (Legacy)
The original model. Permissions are assigned directly on the vault to specific identities.
# Grant the service principal read access to secrets
az keyvault set-policy \
--name kv-devops-pipeline \
--spn "your-service-principal-app-id" \
--secret-permissions get list
Downsides: no conditional access, no fine-grained scope (it is all-or-nothing per vault), harder to audit, permissions do not show up in Azure RBAC tooling.
Azure RBAC (Recommended)
The modern model. Uses standard Azure role assignments, which means you get conditional access, PIM (Privileged Identity Management), and unified audit logs.
# Get the service principal object ID
SP_OBJECT_ID=$(az ad sp show --id "your-service-principal-app-id" --query id -o tsv)
# Assign Key Vault Secrets User role (read-only access to secrets)
az role assignment create \
--role "Key Vault Secrets User" \
--assignee-object-id $SP_OBJECT_ID \
--scope "/subscriptions/{sub-id}/resourceGroups/rg-devops-secrets/providers/Microsoft.KeyVault/vaults/kv-devops-pipeline"
Key roles for pipeline scenarios:
| Role | Permissions | Use Case |
|---|---|---|
| Key Vault Secrets User | Get, List secrets | Pipeline reading secrets |
| Key Vault Secrets Officer | Get, List, Set, Delete, Backup, Restore, Recover, Purge | Admin managing secrets |
| Key Vault Certificates User | Get, List certificates | Pipeline using TLS certs |
| Key Vault Crypto User | Encrypt, Decrypt, Sign, Verify, Wrap, Unwrap | Pipeline cryptographic ops |
Service Connection Permissions for Key Vault Access
The Azure DevOps service connection's service principal needs access to the Key Vault. This is the most common setup failure.
- In Azure DevOps, go to Project Settings > Service connections
- Select your Azure Resource Manager service connection
- Click Manage Service Principal to open it in Azure Portal
- Note the Application (client) ID and Object ID
Then grant the service principal access:
# Using RBAC (recommended)
az role assignment create \
--role "Key Vault Secrets User" \
--assignee-object-id "service-principal-object-id" \
--scope "/subscriptions/{sub-id}/resourceGroups/rg-devops-secrets/providers/Microsoft.KeyVault/vaults/kv-devops-pipeline"
# Verify the assignment
az role assignment list \
--scope "/subscriptions/{sub-id}/resourceGroups/rg-devops-secrets/providers/Microsoft.KeyVault/vaults/kv-devops-pipeline" \
--output table
If you are using vault access policies instead:
az keyvault set-policy \
--name kv-devops-pipeline \
--object-id "service-principal-object-id" \
--secret-permissions get list
Variable Groups Linked to Key Vault
Variable groups are the cleanest way to pull Key Vault secrets into pipelines. The secrets stay in Key Vault — the variable group is just a reference.
Creating a Linked Variable Group
- Go to Pipelines > Library in Azure DevOps
- Click + Variable group
- Toggle Link secrets from an Azure key vault as variables
- Select the Azure service connection and the Key Vault name
- Click + Add and select which secrets to include
- Save the variable group
Or use the Azure DevOps CLI:
# Create the variable group linked to Key Vault
az pipelines variable-group create \
--name "Production-Secrets" \
--authorize true \
--variables dummy=placeholder \
--organization "https://dev.azure.com/your-org" \
--project "YourProject"
Note: the CLI does not fully support Key Vault-linked variable groups yet. You will need to use the REST API or the portal for the Key Vault link. Here is the REST API approach:
// scripts/create-kv-variable-group.js
var https = require("https");
var ORG = process.env.AZURE_ORG;
var PROJECT = process.env.AZURE_PROJECT;
var PAT = process.env.AZURE_PAT;
var SERVICE_CONNECTION_ID = process.env.SERVICE_CONNECTION_ID;
var VAULT_NAME = process.env.VAULT_NAME;
var body = JSON.stringify({
name: "Production-Secrets",
type: "AzureKeyVault",
providerData: {
serviceEndpointId: SERVICE_CONNECTION_ID,
vault: VAULT_NAME
},
variables: {
"DatabaseConnectionString": {
isSecret: true,
value: null,
enabled: true,
contentType: ""
},
"ApiKey": {
isSecret: true,
value: null,
enabled: true,
contentType: ""
},
"JwtSigningKey": {
isSecret: true,
value: null,
enabled: true,
contentType: ""
}
}
});
var auth = Buffer.from(":" + PAT).toString("base64");
var options = {
hostname: "dev.azure.com",
path: "/" + ORG + "/" + PROJECT + "/_apis/distributedtask/variablegroups?api-version=7.1",
method: "POST",
headers: {
"Authorization": "Basic " + auth,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode >= 200 && res.statusCode < 300) {
var result = JSON.parse(data);
console.log("Variable group created: ID " + result.id);
console.log("Name: " + result.name);
console.log("Variables: " + Object.keys(result.variables).join(", "));
} else {
console.error("Error " + res.statusCode + ": " + data);
}
});
});
req.on("error", function(err) { console.error("Request failed:", err.message); });
req.write(body);
req.end();
Referencing Variable Groups in YAML
variables:
- group: Production-Secrets
steps:
- script: |
echo "Connecting to database..."
node deploy.js
displayName: "Deploy with secrets"
env:
DB_CONNECTION: $(DatabaseConnectionString)
API_KEY: $(ApiKey)
JWT_KEY: $(JwtSigningKey)
Key Vault secrets are automatically masked in pipeline logs. If your code accidentally prints a secret value, Azure DevOps replaces it with ***.
The AzureKeyVault Pipeline Task
For more control over which secrets to fetch and when, use the AzureKeyVault@2 task directly:
steps:
- task: AzureKeyVault@2
displayName: "Fetch secrets from Key Vault"
inputs:
azureSubscription: "MyAzureServiceConnection"
KeyVaultName: "kv-devops-pipeline"
SecretsFilter: "DatabaseConnectionString,ApiKey,JwtSigningKey"
RunAsPreJob: false
- script: |
echo "Database secret is available as $(DatabaseConnectionString)"
node app.js
env:
DB_CONN: $(DatabaseConnectionString)
API_KEY: $(ApiKey)
SecretsFilter Options
# Fetch all secrets (use sparingly — pulls everything)
SecretsFilter: "*"
# Fetch specific secrets (recommended)
SecretsFilter: "DatabaseConnectionString,ApiKey,JwtSigningKey"
# Fetch secrets matching a prefix (not natively supported — use *)
# Filter in a subsequent script step instead
SecretsFilter: "*"
RunAsPreJob
Set RunAsPreJob: true to fetch secrets before any other steps run. This is useful when other tasks need the secrets during initialization:
steps:
- task: AzureKeyVault@2
inputs:
azureSubscription: "MyAzureServiceConnection"
KeyVaultName: "kv-devops-pipeline"
SecretsFilter: "*"
RunAsPreJob: true
Fetching Certificates and Keys
Key Vault stores three types of objects: secrets, keys, and certificates. The AzureKeyVault@2 task only fetches secrets. For certificates and keys, use the Azure CLI task:
steps:
- task: AzureCLI@2
displayName: "Download TLS certificate"
inputs:
azureSubscription: "MyAzureServiceConnection"
scriptType: "bash"
scriptLocation: "inlineScript"
inlineScript: |
# Download certificate as PFX
az keyvault certificate download \
--vault-name kv-devops-pipeline \
--name "tls-certificate" \
--file $(Build.ArtifactStagingDirectory)/cert.pfx \
--encoding PEM
# Download the private key (stored as a secret with same name as cert)
az keyvault secret show \
--vault-name kv-devops-pipeline \
--name "tls-certificate" \
--query value -o tsv | base64 -d > $(Build.ArtifactStagingDirectory)/cert.key
echo "Certificate downloaded to $(Build.ArtifactStagingDirectory)"
For cryptographic keys:
- task: AzureCLI@2
displayName: "Sign artifact with Key Vault key"
inputs:
azureSubscription: "MyAzureServiceConnection"
scriptType: "bash"
scriptLocation: "inlineScript"
inlineScript: |
# Create a hash of the artifact
sha256sum $(Build.ArtifactStagingDirectory)/app.zip | awk '{print $1}' > hash.txt
# Sign the hash using a Key Vault key
az keyvault key sign \
--vault-name kv-devops-pipeline \
--name "signing-key" \
--algorithm RS256 \
--digest $(cat hash.txt) \
--output tsv > signature.txt
echo "Artifact signed"
Secret Rotation with Key Vault
Key Vault supports versioning — every time you update a secret, a new version is created. Pipelines always fetch the latest version unless you specify otherwise.
Manual Rotation
# Update a secret (creates a new version automatically)
az keyvault secret set \
--vault-name kv-devops-pipeline \
--name "DatabaseConnectionString" \
--value "Server=prod-db.postgres.database.azure.com;Database=appdb;User Id=appuser;Password=N3wP@ssw0rd!2026;"
# The next pipeline run automatically picks up the new value
# No pipeline changes needed
Automated Rotation with Event Grid
Set up automated secret rotation using Azure Event Grid and an Azure Function:
// azure-function/rotate-secret/index.js
var https = require("https");
module.exports = function(context, eventGridEvent) {
context.log("Secret rotation triggered:", eventGridEvent.subject);
var secretName = eventGridEvent.subject.split("/").pop();
var vaultName = eventGridEvent.data.VaultName;
context.log("Rotating secret: " + secretName + " in vault: " + vaultName);
// Generate new credential (example: rotate a database password)
if (secretName === "DatabaseConnectionString") {
var newPassword = generatePassword(32);
// Update the database password first
updateDatabasePassword(newPassword, function(err) {
if (err) {
context.log.error("Database password update failed:", err.message);
context.done(err);
return;
}
// Then update Key Vault with the new connection string
var newConnString = "Server=prod-db.postgres.database.azure.com;Database=appdb;" +
"User Id=appuser;Password=" + newPassword + ";";
updateKeyVaultSecret(vaultName, secretName, newConnString, function(err2) {
if (err2) {
context.log.error("Key Vault update failed:", err2.message);
context.done(err2);
return;
}
context.log("Secret rotated successfully");
context.done();
});
});
} else {
context.log("No rotation handler for: " + secretName);
context.done();
}
};
function generatePassword(length) {
var chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
var password = "";
for (var i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}
function updateDatabasePassword(newPassword, callback) {
// Implementation depends on your database provider
// For Azure PostgreSQL, use the Azure Management API
context.log("Updating database password...");
callback(null);
}
function updateKeyVaultSecret(vaultName, secretName, value, callback) {
// Use managed identity to authenticate to Key Vault
// The Azure Function's managed identity needs Key Vault Secrets Officer role
var body = JSON.stringify({ value: value });
var options = {
hostname: vaultName + ".vault.azure.net",
path: "/secrets/" + secretName + "?api-version=7.4",
method: "PUT",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
// In production, get the Bearer token from managed identity
// This is simplified — use @azure/identity in real code
callback(null);
}
Configure the Event Grid subscription:
# Create Event Grid subscription for secret near-expiry events
az eventgrid event-subscription create \
--name "secret-rotation" \
--source-resource-id "/subscriptions/{sub-id}/resourceGroups/rg-devops-secrets/providers/Microsoft.KeyVault/vaults/kv-devops-pipeline" \
--endpoint "/subscriptions/{sub-id}/resourceGroups/rg-devops-secrets/providers/Microsoft.Web/sites/func-secret-rotation/functions/rotate-secret" \
--endpoint-type azurefunction \
--included-event-types "Microsoft.KeyVault.SecretNearExpiry" "Microsoft.KeyVault.SecretExpired"
Set expiration dates on secrets to trigger rotation:
# Set a secret with a 90-day expiration
EXPIRY=$(date -u -d "+90 days" +%Y-%m-%dT%H:%M:%SZ)
az keyvault secret set \
--vault-name kv-devops-pipeline \
--name "ApiKey" \
--value "sk-prod-new-key-value" \
--expires "$EXPIRY"
# The SecretNearExpiry event fires 30 days before expiration by default
Complete Working Example
A multi-stage pipeline that uses Key Vault secrets across staging and production environments:
# azure-pipelines.yml
trigger:
branches:
include:
- main
pool:
vmImage: "ubuntu-latest"
variables:
- group: Shared-Secrets
- name: nodeVersion
value: "20.x"
stages:
- stage: Build
displayName: "Build and Test"
jobs:
- job: BuildJob
steps:
- task: NodeTool@0
inputs:
versionSpec: $(nodeVersion)
displayName: "Install Node.js"
- script: npm ci
displayName: "Install dependencies"
- script: npm test
displayName: "Run tests"
- script: npm run build
displayName: "Build application"
- task: PublishPipelineArtifact@1
inputs:
targetPath: "$(Build.SourcesDirectory)/dist"
artifact: "app-bundle"
- stage: DeployStaging
displayName: "Deploy to Staging"
dependsOn: Build
variables:
- group: Staging-Secrets
jobs:
- deployment: StagingDeploy
environment: staging
strategy:
runOnce:
deploy:
steps:
- task: AzureKeyVault@2
displayName: "Fetch staging secrets"
inputs:
azureSubscription: "Azure-Staging"
KeyVaultName: "kv-staging-pipeline"
SecretsFilter: "DatabaseConnectionString,ApiKey,RedisConnectionString"
RunAsPreJob: false
- task: DownloadPipelineArtifact@2
inputs:
artifact: "app-bundle"
targetPath: "$(Pipeline.Workspace)/app"
- task: AzureCLI@2
displayName: "Deploy to staging App Service"
inputs:
azureSubscription: "Azure-Staging"
scriptType: "bash"
scriptLocation: "inlineScript"
inlineScript: |
az webapp config appsettings set \
--name app-staging \
--resource-group rg-staging \
--settings \
DATABASE_URL="$(DatabaseConnectionString)" \
API_KEY="$(ApiKey)" \
REDIS_URL="$(RedisConnectionString)" \
NODE_ENV="staging"
az webapp deploy \
--name app-staging \
--resource-group rg-staging \
--src-path "$(Pipeline.Workspace)/app" \
--type zip
- script: |
echo "Running smoke tests against staging..."
node scripts/smoke-test.js https://app-staging.azurewebsites.net
displayName: "Smoke test staging"
env:
API_KEY: $(ApiKey)
- stage: DeployProduction
displayName: "Deploy to Production"
dependsOn: DeployStaging
variables:
- group: Production-Secrets
jobs:
- deployment: ProdDeploy
environment: production
strategy:
runOnce:
deploy:
steps:
- task: AzureKeyVault@2
displayName: "Fetch production secrets"
inputs:
azureSubscription: "Azure-Production"
KeyVaultName: "kv-devops-pipeline"
SecretsFilter: "DatabaseConnectionString,ApiKey,JwtSigningKey,RedisConnectionString"
RunAsPreJob: false
- task: DownloadPipelineArtifact@2
inputs:
artifact: "app-bundle"
targetPath: "$(Pipeline.Workspace)/app"
- task: AzureCLI@2
displayName: "Deploy to production App Service"
inputs:
azureSubscription: "Azure-Production"
scriptType: "bash"
scriptLocation: "inlineScript"
inlineScript: |
az webapp config appsettings set \
--name app-production \
--resource-group rg-production \
--settings \
DATABASE_URL="$(DatabaseConnectionString)" \
API_KEY="$(ApiKey)" \
JWT_SIGNING_KEY="$(JwtSigningKey)" \
REDIS_URL="$(RedisConnectionString)" \
NODE_ENV="production"
az webapp deploy \
--name app-production \
--resource-group rg-production \
--src-path "$(Pipeline.Workspace)/app" \
--type zip
- script: |
echo "Running production health check..."
node scripts/health-check.js https://app-production.azurewebsites.net
displayName: "Health check production"
Application code that reads secrets from environment variables set by the pipeline:
// config/secrets.js
var config = {
database: {
connectionString: process.env.DATABASE_URL || process.env.DB_CONNECTION || ""
},
api: {
key: process.env.API_KEY || ""
},
jwt: {
signingKey: process.env.JWT_SIGNING_KEY || ""
},
redis: {
url: process.env.REDIS_URL || ""
}
};
// Validate required secrets at startup
function validateSecrets() {
var missing = [];
if (!config.database.connectionString) { missing.push("DATABASE_URL"); }
if (!config.api.key) { missing.push("API_KEY"); }
if (missing.length > 0) {
console.error("Missing required secrets: " + missing.join(", "));
console.error("Ensure Key Vault secrets are configured and the pipeline fetches them.");
process.exit(1);
}
console.log("All required secrets loaded successfully.");
}
module.exports = {
config: config,
validateSecrets: validateSecrets
};
Common Issues and Troubleshooting
"The user, group or application ... does not have secrets get permission on key vault"
##[error] Get secrets failed. Error: Could not fetch access token for Azure.
Status code: 403. Error: The user, group or application 'appid=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx;oid=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' does not have secrets get permission on key vault 'kv-devops-pipeline;location=eastus'.
The service principal behind your Azure DevOps service connection does not have access to the Key Vault. If using RBAC, assign the Key Vault Secrets User role scoped to the vault. If using access policies, add a policy granting get and list permissions for secrets. Changes take up to 10 minutes to propagate.
"AzureKeyVault task: Could not fetch access token for Managed Service Identity"
##[error] Could not fetch access token for Managed Service Identity. Please configure Managed Service Identity for virtual machine 'https://management.azure.com/...'
This happens when using self-hosted agents without a system-assigned managed identity. Either switch to a Microsoft-hosted agent, configure a managed identity on the VM, or ensure the service connection uses a service principal (not managed identity) authentication.
Variable group shows "0 variables" after linking to Key Vault
You linked the variable group to Key Vault but did not add any secrets to it. Click + Add in the variable group settings, and you will see a list of secrets available in the vault. Select the ones you need. The variable group does not automatically include all secrets — you must explicitly choose which ones to expose to pipelines.
"Secret not found: MySecret" even though it exists in Key Vault
##[error] Secret not found: MySecret. Check that the secret exists in the vault 'kv-devops-pipeline' and the service principal has get permissions.
Secret names in Key Vault are case-sensitive. If the secret is named mySecret in Key Vault but you reference MySecret in the SecretsFilter, it will not match. Verify the exact secret name with az keyvault secret list --vault-name kv-devops-pipeline. Also check that the secret has not been disabled or soft-deleted.
Secrets work in classic pipelines but not in YAML
Classic release pipelines use a different authentication path for variable groups. In YAML pipelines, you must explicitly reference the variable group with - group: GroupName in the variables section. Also ensure the pipeline has been authorized to use the variable group — go to Pipelines > Library, select the group, and click the pipeline authorization lock icon.
Key Vault firewall blocks pipeline access
##[error] Client address is not authorized and caller is not a trusted service.
If you have Key Vault firewall rules enabled, Microsoft-hosted agents use dynamic IP addresses that change every run. Either add the AzureCloud service tag to the firewall exceptions, use a self-hosted agent with a static IP, or enable "Allow trusted Microsoft services to bypass this firewall" in Key Vault networking settings.
Best Practices
Use RBAC authorization instead of vault access policies. RBAC gives you conditional access, PIM support, and unified audit logs. Access policies are legacy and do not integrate with Azure AD Conditional Access or identity governance.
Create separate Key Vaults per environment. Do not store staging and production secrets in the same vault. Separate vaults mean separate access controls — a compromised staging service connection cannot read production secrets.
Use the
SecretsFilterparameter to fetch only the secrets you need. Fetching all secrets with*is wasteful and increases the blast radius if a pipeline is compromised. List specific secret names.Set expiration dates on all secrets. Even if you do not have automated rotation yet, expiration dates serve as a reminder. Key Vault raises
SecretNearExpiryevents 30 days before expiration, which you can wire to alerts or automation.Reference secrets through variable groups, not inline task outputs. Variable groups provide a single point of management. Changing a secret in Key Vault automatically propagates to every pipeline using the variable group on the next run.
Never log or echo secret values in pipeline scripts. Azure DevOps masks known secret values in logs, but only if they match registered variables. Avoid string manipulation that might expose partial secret values.
Audit Key Vault access with Azure Monitor. Enable diagnostic logging on the Key Vault and send logs to a Log Analytics workspace. Query
AzureDiagnostics | where ResourceType == "VAULTS"to see who accessed what and when.Use managed identities where possible. If your pipeline deploys to Azure resources, prefer managed identity authentication over storing service principal credentials in Key Vault. This eliminates the need to manage the credential at all.