Security

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 (az version 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:

  1. 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.
  2. Set pipeline permissions — only the pipelines that need the connection should have access.
  3. 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

  1. Go to Pipelines > Library > + Variable group
  2. Enable Link secrets from an Azure key vault as variables
  3. Select the service connection and Key Vault
  4. Click + Add and select the specific secrets to include
  5. 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:

  1. Go to Pipelines > Environments > production
  2. Click Approvals and checks
  3. Add Approvals — require sign-off from at least one person (ideally two)
  4. Add Business hours — restrict deployments to working hours
  5. Add Branch control — only allow deployments from main or release/* branches
  6. 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:

  1. Go to Organization Settings > Auditing
  2. Filter by category "Pipelines" and action "Service connection used"
  3. 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. Use vmImage: '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.


References

Powered by Contentful