Secure CI/CD Pipelines in Azure
A pipeline that deploys to production has elevated privileges in your Azure environment. If that pipeline is compromised — through a malicious dependency, an injected script, or a stolen secret — an attacker has a direct path to production infrastructure. This page covers the concrete controls that reduce the blast radius of a pipeline compromise and prevent common attack vectors.
What you are defending against
CI/CD pipelines face a distinct set of threats compared to application code:
- Secret exfiltration. Secrets passed to a pipeline step can be printed to logs, sent to an attacker-controlled endpoint, or read from environment variables in a compromised dependency.
- Supply chain attacks. A malicious npm package, GitHub Action, or pipeline task can execute arbitrary code in your pipeline with access to all its environment variables.
- Privilege escalation. If a pipeline runs with a broad service principal (Contributor at subscription level), a compromised pipeline can modify any resource in the subscription.
- Pipeline injection. If pipeline YAML is constructed from user-supplied input (like PR titles or branch names), an attacker can inject pipeline commands.
Use OIDC instead of service principal secrets
The traditional approach to pipeline authentication uses a Service Principal client secret stored in Azure DevOps pipeline variables. The secret is long-lived, must be rotated manually, and is a valuable target for attackers. Workload Identity Federation (OIDC) eliminates the secret entirely.
GitHub Actions has supported OIDC with Azure for some time. Azure Pipelines added native support via the Workload Identity Federation service connection type.
# Create a service connection using Workload Identity Federation (OIDC)
# This is done in the Azure DevOps UI: Project Settings > Service connections
# > New service connection > Azure Resource Manager > Workload identity federation (automatic)
# Azure DevOps creates the app registration and federated credential automatically.
# You can also create it manually:
APP_ID=$(az ad app create --display-name "azure-pipelines-oidc" --query appId -o tsv)
SP_ID=$(az ad sp create --id $APP_ID --query id -o tsv)
# Grant the SP Contributor on a specific resource group (not the whole subscription)
az role assignment create \
--assignee $SP_ID \
--role Contributor \
--scope /subscriptions/$(az account show --query id -o tsv)/resourceGroups/myapp-prod-rg
# Add federated credential for the Azure DevOps pipeline
az ad app federated-credential create \
--id $APP_ID \
--parameters '{
"name": "azure-devops-federation",
"issuer": "https://vstoken.dev.azure.com/<your-org-id>",
"subject": "sc://<org>/<project>/<service-connection-name>",
"audiences": ["api://AzureADTokenExchange"]
}'
# Pipeline using OIDC service connection — no secrets anywhere
- task: AzureCLI@2
displayName: 'Deploy with OIDC (no secret needed)'
inputs:
azureSubscription: 'My Azure OIDC Connection' # the OIDC service connection
scriptType: 'bash'
scriptLocation: 'inlineScript'
addSpnToEnvironment: true
inlineScript: |
# The pipeline has already authenticated to Azure via OIDC
az webapp deployment source sync --name my-webapp --resource-group myapp-rg
Least-privilege service connections
The default “Contributor at subscription level” scope for service connections is too broad. An attacker who compromises a pipeline with subscription-level Contributor access can create VMs, read storage accounts, modify security rules, and more. Scope service connections to exactly what the pipeline needs.
# Scope a service connection to a specific resource group only
az role assignment create \
--assignee <service-principal-object-id> \
--role Contributor \
--scope /subscriptions/<sub-id>/resourceGroups/myapp-prod-rg
# For pipelines that only deploy to App Service, use the Website Contributor role
az role assignment create \
--assignee <service-principal-object-id> \
--role "Website Contributor" \
--scope /subscriptions/<sub-id>/resourceGroups/myapp-prod-rg/providers/Microsoft.Web/sites/my-webapp
# For pipelines that push to ACR, use AcrPush only
az role assignment create \
--assignee <service-principal-object-id> \
--role AcrPush \
--scope /subscriptions/<sub-id>/resourceGroups/myapp-prod-rg/providers/Microsoft.ContainerRegistry/registries/myregistry
Handling secrets in pipelines
Secrets should never appear in pipeline logs. Azure Pipelines automatically masks values of variables marked as secret, but there are ways they can still leak.
# Correct: reference a Key Vault-backed variable group
variables:
- group: 'MyApp-Prod-Secrets' # linked to Azure Key Vault, secrets are masked
# Correct: mark individual variables as secret
variables:
- name: DATABASE_PASSWORD
value: $(DatabasePassword) # comes from a secret pipeline variable
# Wrong: constructing a secret value from parts (masking may not catch it)
# - bash: echo "Password is: $PART1$PART2" # don't do this
# Correct: pass secrets as environment variables to scripts, not as arguments
- bash: |
dotnet run --connection-string "$DB_CONN_STRING"
env:
DB_CONN_STRING: $(DATABASE_CONNECTION_STRING) # secret variable
# Prevent secret leakage in scripts: set +x disables command echo
- bash: |
set +x # disable xtrace (command echo) for this block
echo "Connecting to database..."
./deploy.sh
set -x # re-enable if needed
displayName: 'Deploy (secrets protected)'
env:
API_KEY: $(API_KEY)
Pinning task versions and dependencies
Every Azure Pipelines task has a major version. Tasks without pinned versions receive automatic minor and patch updates. A supply chain attack on a popular pipeline task could compromise thousands of pipelines. Pin task versions and review task changelogs before accepting updates.
# Pin task major versions explicitly
steps:
- task: AzureCLI@2 # pinned to major version 2
displayName: '...'
- task: Docker@2 # pinned to major version 2
displayName: '...'
# For GitHub Actions used via ADO — reference by commit SHA, not branch
# (This is a GitHub Actions practice; in ADO use the version tag)
# Lock npm dependencies to prevent dependency hijacking
- bash: |
npm ci # uses package-lock.json exactly — no resolution
npm audit --audit-level=high # fail on high severity vulnerabilities
displayName: 'Install and audit dependencies'
Protected resources and pipeline approval
Azure DevOps lets you mark certain resources (service connections, variable groups, environments, agent pools) as protected. Protected resources require explicit pipeline authorization — only pipelines that have been approved by an administrator can use the resource. This prevents a new or forked pipeline from accessing production credentials.
# A pipeline that tries to use a protected service connection
# will be blocked at queue time with:
# "Resource not authorized. You need to authorize the pipeline
# to use the service connection."
# To authorize: go to the service connection settings
# > Security > Pipeline permissions > Add a specific pipeline
# For environments, configure branch protection:
# - Only the main branch can deploy to prod environment
# - Other branches are blocked even if the pipeline YAML tries to target prod
Enable the “Disable creation of classic release pipelines” setting in your Azure DevOps organization. Classic pipelines cannot use Workload Identity Federation and have weaker secret isolation than YAML pipelines. Enforcing YAML-only pipelines gives you a consistent security baseline across your organization.
Self-hosted agent security
Self-hosted agents run in your own environment, which means they persist state between jobs, can access your VNet resources, and inherit whatever credentials are installed on the host. Harden them carefully:
# Run the agent as a dedicated low-privilege service account
# On Linux:
sudo useradd -m -s /bin/bash azagent
sudo -u azagent ./config.sh --unattended ...
# On the agent machine, limit what the agent user can access
# The agent should not have sudo, should not be in the docker group
# (unless needed for Docker builds), and should not have write access
# to directories outside its working directory.
# Use ephemeral agents on containers for maximum isolation
# Every pipeline job gets a fresh container — no state persists
docker run --rm \
-e AZP_URL="https://dev.azure.com/contoso" \
-e AZP_TOKEN="$(PAT_TOKEN)" \
-e AZP_POOL="MyContainerPool" \
mcr.microsoft.com/azure-pipelines/vsts-agent:ubuntu-20.04
Common mistakes
- Using a single service connection for all pipelines. If ten pipelines share one Contributor-level service connection and one pipeline is compromised, all ten pipelines — and everything that service connection can access — are exposed. Create separate, scoped service connections per pipeline or per deployment target.
- Not reviewing fork PRs before running pipelines against them. When a fork PR triggers a pipeline, that pipeline runs the forked code — including the pipeline YAML — on your agents, with access to your resources. In Azure DevOps, configure PR pipelines to require a reviewer to trigger builds from fork contributors before the pipeline runs for the first time.
- Storing secrets in pipeline YAML committed to git. Even encrypted secrets in a YAML file are problematic — they can be decrypted if the encryption key is compromised, and they end up in git history permanently. Use Key Vault-backed variable groups or Azure DevOps secret variables, not inline YAML values.
Summary
- Use Workload Identity Federation (OIDC) service connections to eliminate long-lived client secrets from pipelines entirely.
- Scope service connections to the minimum required — a specific resource group or resource, not the entire subscription.
- Store secrets in Azure Key Vault-backed variable groups, not as plain pipeline variables or hardcoded YAML values.
- Mark service connections, variable groups, and environments as protected resources so only authorized pipelines can use them.
- For self-hosted agents, run the agent process as a low-privilege dedicated service account and consider ephemeral container-based agents for maximum isolation between jobs.
Frequently asked questions
What is OIDC authentication and why is it better than a service principal secret for pipelines?
OIDC (OpenID Connect) allows a pipeline to authenticate to Azure using a short-lived token issued by the pipeline platform (Azure DevOps or GitHub Actions) instead of a long-lived client secret. There is no secret to store, rotate, or accidentally leak. The token is valid only for the duration of the pipeline run and is automatically scoped to the calling pipeline.
Should I use Microsoft-hosted agents or self-hosted agents for security?
Microsoft-hosted agents get a fresh, clean VM for every job — nothing persists between runs, reducing the risk of one pipeline contaminating another. Self-hosted agents can accumulate state between runs and may have access to private networks and credentials that other pipelines on the same agent can reach. Use self-hosted agents when you need VNet access, but isolate them carefully and clean up after each run.
How do I prevent one pipeline from accessing another pipeline's secrets?
Variable groups with secrets should be linked only to the specific pipelines that need them, not shared broadly. Use Azure Key Vault-backed variable groups so secrets are stored in Key Vault and retrieved at runtime. Enable the "Limit variables accessible from pipelines" option in Azure DevOps organization settings to require explicit pipeline approval for each variable group.