Security

Azure Key Vault Integration with Azure DevOps

Integrate Azure Key Vault with Azure DevOps for secret management, certificate handling, and automated rotation in pipelines

Azure Key Vault Integration with Azure DevOps

Azure Key Vault solves the fundamental problem of secret sprawl in CI/CD pipelines. Instead of scattering database passwords, API keys, and certificates across variable groups, pipeline definitions, and config files, you centralize them in a purpose-built vault with full audit logging and access control. This article walks through every practical aspect of connecting Key Vault to Azure DevOps — from basic pipeline tasks to automated secret rotation and multi-environment strategies.

Prerequisites

  • An Azure subscription with permissions to create resources
  • An Azure DevOps organization and project
  • Azure CLI installed locally (version 2.50+)
  • Node.js 18+ for the SDK examples
  • Basic familiarity with YAML pipelines in Azure DevOps
  • A service connection configured in Azure DevOps (Azure Resource Manager type)

Azure Key Vault Fundamentals

Key Vault stores three types of objects: secrets (arbitrary strings like connection strings and API keys), keys (cryptographic keys for encryption and signing), and certificates (X.509 certificates with optional private key management). For DevOps pipeline integration, you will primarily work with secrets and certificates.

Every Key Vault has a DNS name following the pattern https://{vault-name}.vault.azure.net. Each object within the vault is versioned automatically — when you update a secret, the previous version remains accessible by its version identifier. This matters for rollback scenarios.

Key Vault enforces two layers of access control. The management plane controls who can create, delete, and configure the vault itself (always Azure RBAC). The data plane controls who can read, write, and manage the actual secrets, keys, and certificates stored inside. The data plane supports both the legacy access policy model and the newer Azure RBAC model.

Creating Key Vault with Azure CLI

Start by creating a resource group and Key Vault. I recommend using a naming convention that encodes the environment:

# Create resource group
az group create \
  --name rg-myapp-dev \
  --location eastus2

# Create Key Vault with RBAC authorization
az keyvault create \
  --name kv-myapp-dev \
  --resource-group rg-myapp-dev \
  --location eastus2 \
  --enable-rbac-authorization true \
  --sku standard

# Add a secret
az keyvault secret set \
  --vault-name kv-myapp-dev \
  --name "DatabaseConnectionString" \
  --value "mongodb+srv://admin:s3cureP@[email protected]/myapp"

# Add another secret
az keyvault secret set \
  --vault-name kv-myapp-dev \
  --name "ApiKey" \
  --value "sk-live-abc123def456"

# Add a secret with an expiration date
az keyvault secret set \
  --vault-name kv-myapp-dev \
  --name "ThirdPartyToken" \
  --value "tok_xyz789" \
  --expires "2026-06-01T00:00:00Z"

The --enable-rbac-authorization true flag is important. It tells Key Vault to use Azure RBAC for data plane access instead of the older access policies. I strongly recommend RBAC for new vaults — it is more consistent with how you manage permissions across all other Azure resources.

The AzureKeyVault Pipeline Task

The most direct way to pull secrets into a pipeline is the AzureKeyVault@2 task. It reads secrets from a vault and maps them to pipeline variables:

trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: AzureKeyVault@2
    inputs:
      azureSubscription: 'MyAzureServiceConnection'
      KeyVaultName: 'kv-myapp-dev'
      SecretsFilter: 'DatabaseConnectionString,ApiKey'
      RunAsPreJob: true
    displayName: 'Fetch secrets from Key Vault'

  - script: |
      echo "Deploying with secrets loaded as pipeline variables"
      # Secrets are available as $(DatabaseConnectionString) and $(ApiKey)
      # They are automatically masked in logs
    displayName: 'Deploy application'

Key behaviors to understand:

  • SecretsFilter accepts a comma-separated list of secret names or * for all secrets. Never use * in production — fetch only what you need.
  • RunAsPreJob makes secrets available to all subsequent steps in the job, including downstream tasks. Without this, secrets are only available in steps that follow the task within the same job.
  • Secret values are automatically masked in pipeline logs. If a log line contains the literal secret value, Azure DevOps replaces it with ***.
  • Secret names containing hyphens get converted to underscores in pipeline variables. A secret named my-api-key becomes the variable $(my_api_key).

Variable Groups Linked to Key Vault

For teams that want a more declarative approach, you can link an Azure DevOps variable group directly to a Key Vault. This creates a live binding — the variable group always reflects the current state of the vault.

To set this up in the Azure DevOps UI:

  1. Navigate to Pipelines > Library
  2. Click "+ Variable group"
  3. Toggle "Link secrets from an Azure key vault as variables"
  4. Select your service connection and vault name
  5. Click "+ Add" and choose which secrets to include
  6. Save the variable group

Then reference it in your pipeline:

trigger:
  - main

variables:
  - group: 'MyApp-KeyVault-Secrets'

pool:
  vmImage: 'ubuntu-latest'

stages:
  - stage: Deploy
    jobs:
      - job: DeployApp
        steps:
          - script: |
              echo "Database connection available as variable"
              node deploy.js
            env:
              DB_CONNECTION: $(DatabaseConnectionString)
              API_KEY: $(ApiKey)
            displayName: 'Deploy with secrets'

The variable group approach has one advantage over the AzureKeyVault task: you can manage which secrets are exposed through the DevOps UI without modifying the pipeline YAML. This is useful when non-engineering team members need to manage secret bindings.

Accessing Secrets in YAML Pipelines

There are several patterns for consuming Key Vault secrets in YAML pipelines. Here is the pattern I use most often — passing secrets as environment variables to a Node.js deployment script:

stages:
  - stage: Build
    jobs:
      - job: BuildAndTest
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '20.x'
            displayName: 'Install Node.js'

          - task: AzureKeyVault@2
            inputs:
              azureSubscription: 'MyAzureServiceConnection'
              KeyVaultName: 'kv-myapp-dev'
              SecretsFilter: 'DatabaseConnectionString,ApiKey,JwtSecret'
              RunAsPreJob: true

          - script: |
              npm ci
              npm test
            displayName: 'Install and test'
            env:
              DATABASE_URL: $(DatabaseConnectionString)
              API_KEY: $(ApiKey)
              JWT_SECRET: $(JwtSecret)

          - script: |
              npm run build
            displayName: 'Build application'

  - stage: Deploy
    dependsOn: Build
    jobs:
      - deployment: DeployToDev
        environment: 'dev'
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureKeyVault@2
                  inputs:
                    azureSubscription: 'MyAzureServiceConnection'
                    KeyVaultName: 'kv-myapp-dev'
                    SecretsFilter: 'DatabaseConnectionString,ApiKey,JwtSecret'

                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: 'MyAzureServiceConnection'
                    appName: 'myapp-dev'
                    appSettings: |
                      -DATABASE_URL $(DatabaseConnectionString)
                      -API_KEY $(ApiKey)
                      -JWT_SECRET $(JwtSecret)

Notice that you need to fetch secrets again in the Deploy stage. Secrets do not carry across stages — each stage runs in an isolated context.

Key Vault Access Policies vs. RBAC

There are two authorization models for the Key Vault data plane:

Access Policies (Legacy)

Access policies are configured directly on the vault. Each policy grants a specific principal (user, group, service principal, or managed identity) a set of permissions for secrets, keys, and certificates independently.

# Grant a service principal access to secrets
az keyvault set-policy \
  --name kv-myapp-dev \
  --spn "00000000-0000-0000-0000-000000000000" \
  --secret-permissions get list

The problem with access policies is they are not part of the standard Azure RBAC system. You cannot use Privileged Identity Management (PIM) for just-in-time access. You cannot create conditional access rules. You cannot manage them through Azure Policy the same way.

Azure RBAC (Recommended)

With RBAC authorization enabled, you use standard role assignments. Azure provides built-in roles specifically for Key Vault:

  • Key Vault Secrets User — read secret values (what pipelines need)
  • Key Vault Secrets Officer — full CRUD on secrets
  • Key Vault Administrator — full access to all vault objects
  • Key Vault Certificates Officer — manage certificates
  • Key Vault Crypto Officer — manage keys and cryptographic operations
# Get the service principal object ID for your DevOps service connection
SP_ID=$(az ad sp show --id "00000000-0000-0000-0000-000000000000" --query id -o tsv)

# Get the Key Vault resource ID
KV_ID=$(az keyvault show --name kv-myapp-dev --query id -o tsv)

# Assign the Secrets User role
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee $SP_ID \
  --scope $KV_ID

Use RBAC. The consistency with the rest of Azure's permission model is worth it, and access policies have a limit of 1024 entries per vault which can become a real constraint in large organizations.

Managed Identity for Key Vault Access

When your application runs on Azure (App Service, Azure Functions, AKS, VMs), use managed identity instead of storing service principal credentials. Managed identity eliminates the chicken-and-egg problem of needing a secret to access your secrets.

# Enable system-assigned managed identity on App Service
az webapp identity assign \
  --name myapp-dev \
  --resource-group rg-myapp-dev

# Get the principal ID
PRINCIPAL_ID=$(az webapp identity show \
  --name myapp-dev \
  --resource-group rg-myapp-dev \
  --query principalId -o tsv)

# Grant Key Vault Secrets User role
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee $PRINCIPAL_ID \
  --scope $(az keyvault show --name kv-myapp-dev --query id -o tsv)

Then in your Node.js application, use the @azure/identity package with DefaultAzureCredential to authenticate automatically:

var identity = require("@azure/identity");
var secrets = require("@azure/keyvault-secrets");

var credential = new identity.DefaultAzureCredential();
var client = new secrets.SecretClient(
  "https://kv-myapp-dev.vault.azure.net",
  credential
);

function getSecret(name) {
  return client.getSecret(name).then(function (secret) {
    return secret.value;
  });
}

// Usage in Express app startup
function startServer() {
  Promise.all([
    getSecret("DatabaseConnectionString"),
    getSecret("ApiKey"),
    getSecret("JwtSecret"),
  ]).then(function (results) {
    var config = {
      dbConnection: results[0],
      apiKey: results[1],
      jwtSecret: results[2],
    };

    var app = require("./app")(config);
    var port = process.env.PORT || 3000;
    app.listen(port, function () {
      console.log("Server running on port " + port);
    });
  });
}

startServer();

DefaultAzureCredential tries multiple authentication methods in order: environment variables, managed identity, Azure CLI, and others. In production on App Service it picks up the managed identity automatically. Locally it uses your Azure CLI session. No code changes needed between environments.

Secret Rotation with Pipelines

Secret rotation is non-negotiable for production systems. Here is a pipeline that rotates a database password, updates Key Vault, and restarts the application — all triggered on a schedule:

trigger: none

schedules:
  - cron: '0 2 1 * *'
    displayName: 'Monthly secret rotation'
    branches:
      include:
        - main
    always: true

pool:
  vmImage: 'ubuntu-latest'

variables:
  - group: 'Rotation-Config'

steps:
  - task: AzureKeyVault@2
    inputs:
      azureSubscription: 'MyAzureServiceConnection'
      KeyVaultName: 'kv-myapp-prod'
      SecretsFilter: 'DatabaseAdminPassword'
    displayName: 'Get current password'

  - task: AzureCLI@2
    inputs:
      azureSubscription: 'MyAzureServiceConnection'
      scriptType: 'bash'
      scriptLocation: 'inlineScript'
      inlineScript: |
        # Generate new password
        NEW_PASSWORD=$(openssl rand -base64 32)

        # Update the database password (example for Azure PostgreSQL)
        az postgres flexible-server update \
          --name myapp-db-prod \
          --resource-group rg-myapp-prod \
          --admin-password "$NEW_PASSWORD"

        # Update the secret in Key Vault
        az keyvault secret set \
          --vault-name kv-myapp-prod \
          --name "DatabaseAdminPassword" \
          --value "$NEW_PASSWORD" \
          --tags rotatedBy=pipeline rotatedDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)

        # Restart the app to pick up new credentials
        az webapp restart \
          --name myapp-prod \
          --resource-group rg-myapp-prod

        echo "Secret rotated successfully"
    displayName: 'Rotate database password'

  - task: AzureCLI@2
    inputs:
      azureSubscription: 'MyAzureServiceConnection'
      scriptType: 'bash'
      scriptLocation: 'inlineScript'
      inlineScript: |
        # Verify the app is healthy after rotation
        sleep 30
        HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myapp-prod.azurewebsites.net/health)
        if [ "$HTTP_STATUS" -ne 200 ]; then
          echo "##vso[task.logissue type=error]Health check failed after rotation. Status: $HTTP_STATUS"
          echo "##vso[task.complete result=Failed;]"
        else
          echo "Health check passed. Rotation complete."
        fi
    displayName: 'Post-rotation health check'

Tagging rotated secrets with metadata is critical for auditing. When something goes wrong at 3 AM, you need to know when a secret was last rotated and by what process.

Certificate Management

Key Vault can store and manage X.509 certificates, including auto-renewal. This is particularly useful for TLS certificates and code-signing certificates used in pipelines.

# Import an existing PFX certificate
az keyvault certificate import \
  --vault-name kv-myapp-prod \
  --name "AppTlsCert" \
  --file ./certificate.pfx \
  --password "pfx-password"

# Create a self-signed certificate (useful for dev/test)
az keyvault certificate create \
  --vault-name kv-myapp-dev \
  --name "DevTlsCert" \
  --policy "$(az keyvault certificate get-default-policy)"

Retrieve certificates in a pipeline for deployment:

steps:
  - task: AzureKeyVault@2
    inputs:
      azureSubscription: 'MyAzureServiceConnection'
      KeyVaultName: 'kv-myapp-prod'
      SecretsFilter: 'AppTlsCert'
    displayName: 'Fetch TLS certificate'

  - script: |
      echo "$(AppTlsCert)" | base64 --decode > /tmp/cert.pfx
      echo "Certificate retrieved for deployment"
    displayName: 'Decode certificate'

When you retrieve a certificate through the secrets endpoint (which is what the AzureKeyVault task does), you get the full PFX bundle including the private key. This is base64-encoded, so you need to decode it before use.

Key Vault References in App Service

Azure App Service supports Key Vault references directly in application settings. Instead of fetching secrets at deployment time, the app setting points to a Key Vault secret and App Service resolves it at runtime:

# Set an app setting as a Key Vault reference
az webapp config appsettings set \
  --name myapp-prod \
  --resource-group rg-myapp-prod \
  --settings "[email protected](SecretUri=https://kv-myapp-prod.vault.azure.net/secrets/DatabaseConnectionString/)"

az webapp config appsettings set \
  --name myapp-prod \
  --resource-group rg-myapp-prod \
  --settings "[email protected](SecretUri=https://kv-myapp-prod.vault.azure.net/secrets/ApiKey/)"

The advantage here is that when you rotate a secret in Key Vault, the app automatically picks up the new value within 24 hours (or immediately on restart). No pipeline redeployment needed. The App Service must have a managed identity with the Key Vault Secrets User role on the vault.

You can also pin to a specific version if you need deterministic deployments:

az webapp config appsettings set \
  --name myapp-prod \
  --resource-group rg-myapp-prod \
  --settings "[email protected](SecretUri=https://kv-myapp-prod.vault.azure.net/secrets/DatabaseConnectionString/abc123def456)"

Monitoring Secret Access

Enable diagnostic logging on Key Vault to track every secret access:

# Enable diagnostic settings to send logs to Log Analytics
az monitor diagnostic-settings create \
  --name kv-diagnostics \
  --resource $(az keyvault show --name kv-myapp-prod --query id -o tsv) \
  --workspace $(az monitor log-analytics workspace show --name la-myapp --resource-group rg-myapp-prod --query id -o tsv) \
  --logs '[{"categoryGroup":"allLogs","enabled":true}]'

Query for suspicious access patterns in Log Analytics:

AzureDiagnostics
| where ResourceProvider == "MICROSOFT.KEYVAULT"
| where OperationName == "SecretGet"
| summarize AccessCount = count() by CallerIPAddress, Identity = identity_claim_upn_s, bin(TimeGenerated, 1h)
| where AccessCount > 50
| order by AccessCount desc

Set up an alert for failed access attempts — these often indicate misconfigured permissions or potential unauthorized access:

az monitor metrics alert create \
  --name "kv-unauthorized-access" \
  --resource-group rg-myapp-prod \
  --scopes $(az keyvault show --name kv-myapp-prod --query id -o tsv) \
  --condition "total ServiceApiResult > 5" \
  --dimension "StatusCode=403" \
  --window-size 5m \
  --evaluation-frequency 1m \
  --action $(az monitor action-group show --name ops-team --resource-group rg-myapp-prod --query id -o tsv) \
  --description "Alert on Key Vault unauthorized access attempts"

Building a Key Vault Management Tool with Node.js SDK

For teams that need programmatic control over Key Vault — bulk secret operations, custom rotation logic, or integration with internal tools — the Azure SDK for JavaScript is the way to go.

var identity = require("@azure/identity");
var SecretClient = require("@azure/keyvault-secrets").SecretClient;
var MonitorClient = require("@azure/keyvault-admin").KeyVaultBackupClient;

var VAULT_URL = process.env.VAULT_URL || "https://kv-myapp-dev.vault.azure.net";
var credential = new identity.DefaultAzureCredential();
var secretClient = new SecretClient(VAULT_URL, credential);

// List all secrets (names and metadata, not values)
function listSecrets() {
  var secrets = [];
  var iter = secretClient.listPropertiesOfSecrets();

  return new Promise(function (resolve, reject) {
    function next() {
      iter.next().then(function (result) {
        if (result.done) {
          resolve(secrets);
          return;
        }
        secrets.push({
          name: result.value.name,
          enabled: result.value.enabled,
          created: result.value.createdOn,
          updated: result.value.updatedOn,
          expires: result.value.expiresOn,
          tags: result.value.tags,
        });
        next();
      }).catch(reject);
    }
    next();
  });
}

// Get a specific secret value
function getSecret(name) {
  return secretClient.getSecret(name).then(function (secret) {
    return {
      name: secret.name,
      value: secret.value,
      version: secret.properties.version,
      created: secret.properties.createdOn,
    };
  });
}

// Set or update a secret with metadata
function setSecret(name, value, tags) {
  return secretClient.setSecret(name, value, {
    tags: tags || {},
    contentType: "text/plain",
  }).then(function (result) {
    console.log("Secret '" + name + "' updated. Version: " + result.properties.version);
    return result;
  });
}

// Bulk import secrets from a JSON file
function bulkImport(filePath) {
  var fs = require("fs");
  var secretData = JSON.parse(fs.readFileSync(filePath, "utf8"));
  var keys = Object.keys(secretData);
  var index = 0;

  return new Promise(function (resolve, reject) {
    function processNext() {
      if (index >= keys.length) {
        resolve({ imported: keys.length });
        return;
      }
      var key = keys[index];
      index++;

      setSecret(key, secretData[key], { importedBy: "bulk-import" })
        .then(function () {
          console.log("Imported " + index + "/" + keys.length + ": " + key);
          processNext();
        })
        .catch(reject);
    }
    processNext();
  });
}

// Find secrets expiring within N days
function findExpiringSecrets(days) {
  var cutoff = new Date();
  cutoff.setDate(cutoff.getDate() + days);

  return listSecrets().then(function (allSecrets) {
    return allSecrets.filter(function (s) {
      return s.expires && new Date(s.expires) < cutoff;
    });
  });
}

// Purge deleted secrets (for vaults with soft-delete)
function purgeDeletedSecrets() {
  var iter = secretClient.listDeletedSecrets();
  var purged = [];

  return new Promise(function (resolve, reject) {
    function next() {
      iter.next().then(function (result) {
        if (result.done) {
          resolve(purged);
          return;
        }
        var name = result.value.name;
        secretClient.purgeDeletedSecret(name).then(function () {
          purged.push(name);
          console.log("Purged: " + name);
          next();
        }).catch(function (err) {
          console.error("Failed to purge " + name + ": " + err.message);
          next();
        });
      }).catch(reject);
    }
    next();
  });
}

// CLI interface
var command = process.argv[2];

if (command === "list") {
  listSecrets().then(function (secrets) {
    console.log(JSON.stringify(secrets, null, 2));
  });
} else if (command === "get") {
  getSecret(process.argv[3]).then(function (secret) {
    console.log(JSON.stringify(secret, null, 2));
  });
} else if (command === "set") {
  setSecret(process.argv[3], process.argv[4]).then(function () {
    console.log("Done.");
  });
} else if (command === "import") {
  bulkImport(process.argv[3]).then(function (result) {
    console.log("Import complete: " + JSON.stringify(result));
  });
} else if (command === "expiring") {
  var days = parseInt(process.argv[3]) || 30;
  findExpiringSecrets(days).then(function (secrets) {
    console.log("Secrets expiring within " + days + " days:");
    console.log(JSON.stringify(secrets, null, 2));
  });
} else {
  console.log("Usage: node kv-tool.js <list|get|set|import|expiring> [args]");
}

Install the required packages:

npm install @azure/identity @azure/keyvault-secrets @azure/keyvault-admin

This tool is useful for operations teams who need to audit secrets, find expiring credentials, or perform bulk migrations between environments.

Multi-Environment Key Vault Strategy

Most teams need at least three environments (dev, staging, production), and each should have its own Key Vault. Here is how I structure it:

kv-myapp-dev      → rg-myapp-dev      → Developers have Secrets Officer
kv-myapp-staging  → rg-myapp-staging  → Developers have Secrets User only
kv-myapp-prod     → rg-myapp-prod     → Only CI/CD and managed identities

Use pipeline parameters to select the correct vault:

parameters:
  - name: environment
    type: string
    default: 'dev'
    values:
      - dev
      - staging
      - prod

variables:
  keyVaultName: 'kv-myapp-${{ parameters.environment }}'
  azureSubscription: 'Azure-${{ parameters.environment }}'

stages:
  - stage: Deploy
    jobs:
      - deployment: DeployApp
        environment: ${{ parameters.environment }}
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureKeyVault@2
                  inputs:
                    azureSubscription: $(azureSubscription)
                    KeyVaultName: $(keyVaultName)
                    SecretsFilter: 'DatabaseConnectionString,ApiKey,JwtSecret'

                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: $(azureSubscription)
                    appName: 'myapp-${{ parameters.environment }}'
                    appSettings: |
                      -DATABASE_URL $(DatabaseConnectionString)
                      -API_KEY $(ApiKey)
                      -JWT_SECRET $(JwtSecret)
                      -NODE_ENV ${{ parameters.environment }}

Critical rule: secret names must be identical across all vaults. The values differ, but DatabaseConnectionString in dev, staging, and prod must all exist. This lets your pipeline YAML remain environment-agnostic — only the vault name changes.

Use Azure Policy to enforce this consistency:

# Ensure all Key Vaults have soft-delete enabled (built-in policy)
az policy assignment create \
  --name "require-kv-soft-delete" \
  --policy "/providers/Microsoft.Authorization/policyDefinitions/1e66c121-a66a-4b1f-9b83-0fd99bf0fc2d" \
  --scope "/subscriptions/$(az account show --query id -o tsv)"

Complete Working Example

Here is a complete Azure Pipeline that retrieves secrets from Key Vault, deploys a Node.js application, and runs a post-deployment secret rotation check with monitoring:

trigger:
  branches:
    include:
      - main

parameters:
  - name: environment
    type: string
    default: 'dev'
    values:
      - dev
      - staging
      - prod
  - name: rotateSecrets
    type: boolean
    default: false

variables:
  keyVaultName: 'kv-myapp-${{ parameters.environment }}'
  azureSubscription: 'Azure-${{ parameters.environment }}'
  appName: 'myapp-${{ parameters.environment }}'
  resourceGroup: 'rg-myapp-${{ parameters.environment }}'

stages:
  # Stage 1: Build and Test
  - stage: Build
    displayName: 'Build and Test'
    jobs:
      - job: BuildJob
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '20.x'
            displayName: 'Use Node.js 20'

          - task: AzureKeyVault@2
            inputs:
              azureSubscription: $(azureSubscription)
              KeyVaultName: $(keyVaultName)
              SecretsFilter: 'DatabaseConnectionString,ApiKey,JwtSecret,TestDatabaseUrl'
              RunAsPreJob: true
            displayName: 'Load secrets for testing'

          - script: npm ci
            displayName: 'Install dependencies'

          - script: npm test
            displayName: 'Run tests'
            env:
              DATABASE_URL: $(TestDatabaseUrl)
              API_KEY: $(ApiKey)
              JWT_SECRET: $(JwtSecret)
              NODE_ENV: test

          - script: npm run build
            displayName: 'Build application'

          - task: ArchiveFiles@2
            inputs:
              rootFolderOrFile: '$(System.DefaultWorkingDirectory)'
              includeRootFolder: false
              archiveType: 'zip'
              archiveFile: '$(Build.ArtifactStagingDirectory)/app.zip'
            displayName: 'Archive build'

          - publish: $(Build.ArtifactStagingDirectory)/app.zip
            artifact: drop
            displayName: 'Publish artifact'

  # Stage 2: Deploy
  - stage: Deploy
    displayName: 'Deploy to ${{ parameters.environment }}'
    dependsOn: Build
    jobs:
      - deployment: DeployApp
        environment: ${{ parameters.environment }}
        pool:
          vmImage: 'ubuntu-latest'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureKeyVault@2
                  inputs:
                    azureSubscription: $(azureSubscription)
                    KeyVaultName: $(keyVaultName)
                    SecretsFilter: 'DatabaseConnectionString,ApiKey,JwtSecret,SessionSecret,RedisConnectionString'
                  displayName: 'Fetch deployment secrets'

                - task: AzureWebApp@1
                  inputs:
                    azureSubscription: $(azureSubscription)
                    appType: 'webAppLinux'
                    appName: $(appName)
                    package: '$(Pipeline.Workspace)/drop/app.zip'
                    runtimeStack: 'NODE|20-lts'
                    appSettings: |
                      -DATABASE_URL $(DatabaseConnectionString)
                      -API_KEY $(ApiKey)
                      -JWT_SECRET $(JwtSecret)
                      -SESSION_SECRET $(SessionSecret)
                      -REDIS_URL $(RedisConnectionString)
                      -NODE_ENV production
                  displayName: 'Deploy to App Service'

                - task: AzureCLI@2
                  inputs:
                    azureSubscription: $(azureSubscription)
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      echo "Waiting for deployment to stabilize..."
                      sleep 45

                      HEALTH_URL="https://$(appName).azurewebsites.net/health"
                      HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" --max-time 10)

                      if [ "$HTTP_STATUS" -eq 200 ]; then
                        echo "Health check passed (HTTP $HTTP_STATUS)"
                      else
                        echo "##vso[task.logissue type=error]Health check failed. HTTP status: $HTTP_STATUS"
                        echo "##vso[task.complete result=Failed;]Health check failed"
                      fi
                  displayName: 'Post-deploy health check'

  # Stage 3: Secret Rotation (conditional)
  - stage: RotateSecrets
    displayName: 'Secret Rotation'
    dependsOn: Deploy
    condition: and(succeeded(), eq('${{ parameters.rotateSecrets }}', 'true'))
    jobs:
      - job: Rotate
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: AzureCLI@2
            inputs:
              azureSubscription: $(azureSubscription)
              scriptType: 'bash'
              scriptLocation: 'inlineScript'
              inlineScript: |
                TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)

                # Generate new JWT secret
                NEW_JWT=$(openssl rand -base64 64 | tr -d '\n')
                az keyvault secret set \
                  --vault-name $(keyVaultName) \
                  --name "JwtSecret" \
                  --value "$NEW_JWT" \
                  --tags rotatedBy=pipeline rotatedDate=$TIMESTAMP

                # Generate new session secret
                NEW_SESSION=$(openssl rand -base64 48 | tr -d '\n')
                az keyvault secret set \
                  --vault-name $(keyVaultName) \
                  --name "SessionSecret" \
                  --value "$NEW_SESSION" \
                  --tags rotatedBy=pipeline rotatedDate=$TIMESTAMP

                echo "Secrets rotated at $TIMESTAMP"

                # Restart app to pick up new secrets
                az webapp restart \
                  --name $(appName) \
                  --resource-group $(resourceGroup)

                echo "Application restarted"
            displayName: 'Rotate application secrets'

          - task: AzureCLI@2
            inputs:
              azureSubscription: $(azureSubscription)
              scriptType: 'bash'
              scriptLocation: 'inlineScript'
              inlineScript: |
                sleep 30

                HEALTH_URL="https://$(appName).azurewebsites.net/health"
                HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" --max-time 10)

                if [ "$HTTP_STATUS" -eq 200 ]; then
                  echo "Post-rotation health check passed"
                else
                  echo "##vso[task.logissue type=warning]Post-rotation health check returned HTTP $HTTP_STATUS"
                  echo "Initiating rollback..."

                  # Get previous version of secrets
                  PREV_JWT_VERSION=$(az keyvault secret list-versions \
                    --vault-name $(keyVaultName) \
                    --name "JwtSecret" \
                    --query "sort_by([?attributes.enabled], &attributes.created)[-2].id" -o tsv)

                  if [ -n "$PREV_JWT_VERSION" ]; then
                    PREV_JWT_VALUE=$(az keyvault secret show --id "$PREV_JWT_VERSION" --query value -o tsv)
                    az keyvault secret set \
                      --vault-name $(keyVaultName) \
                      --name "JwtSecret" \
                      --value "$PREV_JWT_VALUE" \
                      --tags rolledBack=true rollbackDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)
                    echo "JwtSecret rolled back"
                  fi

                  az webapp restart \
                    --name $(appName) \
                    --resource-group $(resourceGroup)

                  echo "##vso[task.complete result=Failed;]Rotation failed, rollback attempted"
                fi
            displayName: 'Verify rotation and rollback if needed'

  # Stage 4: Monitor
  - stage: Monitor
    displayName: 'Monitoring Verification'
    dependsOn:
      - Deploy
      - RotateSecrets
    condition: succeededOrFailed()
    jobs:
      - job: CheckMonitoring
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: AzureCLI@2
            inputs:
              azureSubscription: $(azureSubscription)
              scriptType: 'bash'
              scriptLocation: 'inlineScript'
              inlineScript: |
                echo "=== Key Vault Access Summary ==="

                # Check for recent failed access attempts
                FAILED=$(az monitor metrics list \
                  --resource $(az keyvault show --name $(keyVaultName) --query id -o tsv) \
                  --metric "ServiceApiResult" \
                  --dimension StatusCode=403 \
                  --interval PT1H \
                  --query "value[0].timeseries[0].data[-1].total" -o tsv 2>/dev/null)

                if [ -n "$FAILED" ] && [ "$FAILED" != "None" ] && [ "$FAILED" -gt 0 ]; then
                  echo "##vso[task.logissue type=warning]$FAILED unauthorized access attempts in the last hour"
                else
                  echo "No unauthorized access attempts detected"
                fi

                # List secrets nearing expiration
                echo ""
                echo "=== Secrets Expiring Within 30 Days ==="
                az keyvault secret list \
                  --vault-name $(keyVaultName) \
                  --query "[?attributes.expires && attributes.expires < '$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)'].{name:name, expires:attributes.expires}" \
                  -o table
            displayName: 'Audit Key Vault access and expiration'

Common Issues and Troubleshooting

1. "The user, group or application does not have secrets get permission"

This is the most common error. If you are using RBAC, the service principal behind your Azure DevOps service connection needs the Key Vault Secrets User role assigned at the vault scope. The role assignment can take up to 10 minutes to propagate. If using access policies, verify the correct principal has Get and List permissions on secrets.

# Quick fix: verify and assign the role
az role assignment list --scope $(az keyvault show --name kv-myapp-dev --query id -o tsv) --output table

# If missing, add it
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee <service-principal-id> \
  --scope $(az keyvault show --name kv-myapp-dev --query id -o tsv)

2. Secret names with hyphens break pipeline variable references

If your secret is named database-connection-string, the AzureKeyVault task converts hyphens to underscores when creating the pipeline variable. You must reference it as $(database_connection_string), not $(database-connection-string). This catches people every time. My recommendation: use PascalCase for secret names (DatabaseConnectionString) to avoid the issue entirely.

3. "Key Vault is using access policies but RBAC role assignment was attempted"

You cannot mix access policies and RBAC on the same vault. Check which mode your vault uses:

az keyvault show --name kv-myapp-dev --query properties.enableRbacAuthorization

If this returns false or null, the vault uses access policies. Either switch to RBAC (az keyvault update --name kv-myapp-dev --enable-rbac-authorization true) or use az keyvault set-policy instead of role assignments. Note that switching to RBAC removes all existing access policies — plan the migration carefully.

4. Secrets not updating in App Service Key Vault references

App Service caches Key Vault reference values. After rotating a secret, the new value does not appear immediately. The cache refreshes roughly every 24 hours, or you can force it by restarting the app or making any change to application settings (even adding a dummy setting). For time-sensitive rotations, always include a restart step in your pipeline.

5. Soft-delete blocking vault recreation

By default, Key Vault enables soft-delete with a 90-day retention period. If you delete a vault and try to recreate it with the same name, you get an error. You must either purge the deleted vault first or recover it:

# List deleted vaults
az keyvault list-deleted

# Purge a deleted vault
az keyvault purge --name kv-myapp-dev

# Or recover it
az keyvault recover --name kv-myapp-dev

Best Practices

  • One vault per environment, per application. Do not share vaults across unrelated applications. The blast radius of a compromised vault should be limited to a single app in a single environment.

  • Use RBAC over access policies. RBAC integrates with Privileged Identity Management, conditional access, and Azure Policy. Access policies are a legacy mechanism with a 1024-entry ceiling.

  • Set expiration dates on all secrets. Even if you do not have automated rotation yet, expiration dates create visibility. Use the findExpiringSecrets pattern from the Node.js tool above to audit regularly.

  • Never log secret values. Even though Azure DevOps masks secrets in logs automatically, do not write code that outputs secret values to stdout. The masking is best-effort and can miss partial matches or encoded values.

  • Use managed identity wherever possible. Service principals require credential rotation themselves. Managed identity credentials are handled entirely by Azure with no manual intervention. Every Azure compute service supports managed identity.

  • Enable diagnostic logging and set up alerts. A vault without logging is a vault you cannot audit. At minimum, alert on 403 responses (unauthorized access attempts) and track the volume of secret reads.

  • Tag secrets with metadata. Use tags to record the rotation schedule, the owner team, the associated application, and the last rotation date. This information is invisible in the secret value itself but critical for operational hygiene.

  • Pin to specific secret versions in production only when necessary. Versionless references (without the version GUID in the URI) always resolve to the latest version, which is what you want for automatic rotation. Pin versions only when you need to coordinate a rollout with a specific secret change.

  • Audit service connection permissions quarterly. Service connections in Azure DevOps often accumulate more permissions than they need. Review what each connection can access and remove the Key Vault Secrets Officer role from connections that only need to read secrets.

References

Powered by Contentful