What you will build
Azure DevOps has one feature the other platforms lack in the same form: a required template check. A protected resource, such as the production service connection, refuses any pipeline that does not extend an approved template. That lets a platform team own the security stages while application teams own only their build steps.
Platform/pipeline-templates # owned by platform-security, tags protected ├── secure-pipeline.yml # stages: Build+scan → Package+sign → Deploy └── notation/ ├── trustpolicy.json # who may sign images we deploy └── truststore/x509/ca/contoso/contoso-signing.pem Payments/payments-api # application team ├── azure-pipelines.yml # 20 lines: extends the template, passes build steps ├── Dockerfile └── src/
The application is the same Node.js service as in the GitHub tutorial, pushed to Azure Container Registry contosoacr and deployed to Azure Container Apps.
Step 1Lock the organization and project
Most of these are switches under Organization settings›Pipelines›Settings. Set them at organization level so new projects inherit them.
Branch policies on main
Under Project settings›Repositories›payments-api›Policies, protect the default branch:
- Require at least one reviewer, prohibit the most recent pusher from approving their own changes, and reset all approval votes when new changes are pushed.
- Add a build validation policy that runs the pipeline on every pull request, set to required. In Azure Repos this, not a
pr:block in YAML, is what triggers PR builds. - Add automatically included reviewers for the path filter
/azure-pipelines.yml, with the Platform Security group as required. Pipeline changes then cannot merge without them.
Protected resources
Service connections, environments, agent pools, variable groups and secure files are protected resources. For each one, turn off "Grant access permission to all pipelines" and authorize pipelines individually. Then add checks to them; you will do that in steps 2 and 6.
Agents
Prefer Microsoft-hosted agents or ephemeral self-hosted agents (Managed DevOps Pools, or scale set agents that tear down the VM after every job). A persistent agent shared across projects keeps files, caches and credentials between jobs.
Step 2Workload identity federation
Create Azure Resource Manager service connections that use workload identity federation. Azure DevOps then requests a short-lived token for each job; there is no client secret to rotate or leak. The automatic setup creates the app registration and federated credential for you. If your security team manages identities, choose the manual option and point it at a user-assigned managed identity.
| Service connection | Used by | Azure role, scope | Checks |
|---|---|---|---|
acr-contoso | Package stage (push) | AcrPush on contosoacr | Branch control: refs/heads/main |
sc-contoso-signing | Notation sign | Key Vault Crypto User and Certificate User on the signing vault | Branch control, required template |
sc-contoso-prod | Deploy stage | Contributor on rg-payments-prod only | Branch control, required template, approval |
Three connections instead of one means the identity that builds code can never change production, and the signing key is used only from main through the platform template. Enable Verify branch protection in each branch control check, so the check also fails if someone removes the policies on main.
Secrets that still exist
Some values cannot be federated, such as a third-party API key. Store them in Azure Key Vault, link the vault to a variable group, and protect the variable group like any other resource. Secret variables are not exposed to scripts automatically; map each one into the single step that needs it:
variables:
- group: payments-prod-secrets # linked to Key Vault, protected resource
steps:
- script: ./scripts/notify-release.sh
env:
STATUSPAGE_TOKEN: $(statuspage-token) # only this step sees it
Step 3The required template
The application pipeline is short on purpose. It declares the templates repository at a reviewed tag and extends the secure template, passing only its build steps.
trigger:
branches:
include: [main]
# Azure Repos PR builds come from the branch policy's build validation, not from 'pr:'.
resources:
repositories:
- repository: templates
type: git
name: Platform/pipeline-templates
ref: refs/tags/v2.3.0 # a reviewed, protected tag
extends:
template: secure-pipeline.yml@templates
parameters:
appName: payments-api
languages: javascript
buildSteps:
- task: NodeTool@0
inputs:
versionSpec: "22.x"
- script: npm ci --ignore-scripts
displayName: Install dependencies
- script: npm test
displayName: Unit tests
The template validates what it is given. It walks every step the caller passed in, rejects tasks that are not on the platform's allowlist, and wraps every caller step in a target restriction so it cannot run agent logging commands or set pipeline variables.
parameters:
- name: appName
type: string
- name: languages
type: string
default: javascript
- name: buildSteps
type: stepList
default: []
# Inside the Build job:
steps:
- ${{ each step in parameters.buildSteps }}:
- ${{ each pair in step }}:
${{ if and(eq(pair.key, 'task'), not(in(pair.value, 'NodeTool@0', 'UseDotNet@2', 'DotNetCoreCLI@2', 'Npm@1'))) }}:
# An unexpected key fails compilation and shows this text as the error.
'Task ${{ pair.value }} is not on the platform allowlist': error
${{ else }}:
${{ pair.key }}: ${{ pair.value }}
target:
commands: restricted # no ##vso logging commands except a safe subset
settableVariables: none # caller steps cannot set pipeline variables
Templates are expanded before the run starts, so a rejected step stops the pipeline at compile time; nothing executes. If a caller's step already sets target, compilation also fails, which is the behaviour you want.
Make the template mandatory
On the production environment and on the sc-contoso-signing and sc-contoso-prod service connections, add the Required template check: repository Platform/pipeline-templates, ref refs/tags/v2.3.0, path secure-pipeline.yml. A pipeline that does not extend that exact template cannot use these resources, even if it is otherwise authorized.
Protect the templates repository
The required template is only as strong as the repository that holds it. Apply branch policies to it, restrict who can create and force-push tags, and review template changes with the same rigor as production code. Bump the tag in the check when you release a new template version.
Step 4Build and scan
The template's first stage runs on every pull request and on main. It uses GitHub Advanced Security for Azure DevOps, sold as two products: Secret Protection (secret scanning and push protection, no pipeline task needed) and Code Security (dependency scanning and CodeQL). Both work only with Azure Repos Git repositories.
- stage: Build
displayName: Build, test and scan
jobs:
- job: build
pool:
vmImage: ubuntu-24.04
timeoutInMinutes: 30
steps:
- checkout: self
fetchDepth: 1
- task: AdvancedSecurity-Codeql-Init@1
inputs:
enableAutomaticCodeQLInstall: true
languages: ${{ parameters.languages }}
querysuite: security-extended
buildtype: None # interpreted languages: no build needed
# caller's build steps, guarded as shown above
- task: AdvancedSecurity-Dependency-Scanning@1
- task: AdvancedSecurity-Codeql-Analyze@1
- script: |
mkdir -p "$(Build.ArtifactStagingDirectory)/sarif"
docker run --rm \
-v "$(Build.SourcesDirectory):/src:ro" \
-v "$(Build.ArtifactStagingDirectory)/sarif:/out" \
aquasec/trivy:0.74.0 config /src \
--severity CRITICAL,HIGH --exit-code 1 \
--format sarif --output /out/trivy-iac.sarif
displayName: IaC scan (Trivy, blocking)
- task: AdvancedSecurity-Publish@1
condition: succeededOrFailed() # publish findings even when the scan failed
inputs:
SarifsInputDirectory: $(Build.ArtifactStagingDirectory)/sarif
Category: trivy-iac
- Order matters. CodeQL init runs before the build steps, and analyze runs after them. Dependency scanning runs after the build so restored packages are present.
- What blocks. The Advanced Security tasks report alerts to the repository's Advanced Security view and annotate pull requests; they do not fail the build on findings. The hard gates in this stage are the tests and the Trivy step, which exits non-zero on critical or high misconfigurations. Triage CodeQL and dependency alerts against the threshold policy in Foundations.
- Pinned images. The Trivy image is pinned to a version. For full immutability, pin it by digest (
aquasec/trivy@sha256:…) and let Renovate update it.
Without Advanced Security
If you do not license Code Security, run open-source scanners as containers with blocking exit codes. Each one fails the job when it finds something at the configured level.
- script: |
docker run --rm -v "$(Build.SourcesDirectory):/src:ro" \
ghcr.io/gitleaks/gitleaks:v8.30.1 dir /src --redact
displayName: Secrets (gitleaks)
- script: |
docker run --rm -v "$(Build.SourcesDirectory):/src:ro" -w /src \
semgrep/semgrep:1.179.0 semgrep scan --config p/default --severity ERROR --error
displayName: SAST (Semgrep)
- script: |
docker run --rm -v "$(Build.SourcesDirectory):/src:ro" \
ghcr.io/google/osv-scanner:v2.5.1 scan source -r /src
displayName: SCA (OSV-Scanner)
Step 5Package, SBOM and sign
The Package stage runs only on main. It builds the image, scans it before it is pushed, generates an SBOM from the built image, pushes, resolves the digest, and signs that digest with Notation using a key held in Azure Key Vault. The key never leaves the vault; the pipeline asks the vault to sign.
- stage: Package
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- job: image
pool:
vmImage: ubuntu-24.04
variables:
image: contosoacr.azurecr.io/${{ parameters.appName }}
steps:
- checkout: self
- task: Docker@2
displayName: Log in to ACR
inputs:
command: login
containerRegistry: acr-contoso
- task: Docker@2
displayName: Build
inputs:
command: build
repository: ${{ parameters.appName }}
containerRegistry: acr-contoso
tags: $(Build.SourceVersion)
- script: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:0.74.0 image \
--severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 \
"$(image):$(Build.SourceVersion)"
displayName: Image scan (release gate)
- script: |
mkdir -p "$(Build.ArtifactStagingDirectory)/sbom"
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v "$(Build.ArtifactStagingDirectory)/sbom:/out" \
anchore/syft:v1.54.1 "docker:$(image):$(Build.SourceVersion)" \
-o spdx-json=/out/sbom.spdx.json
displayName: Generate SBOM
- task: Docker@2
displayName: Push
inputs:
command: push
repository: ${{ parameters.appName }}
containerRegistry: acr-contoso
tags: $(Build.SourceVersion)
- script: |
ref=$(docker inspect --format '{{index .RepoDigests 0}}' "$(image):$(Build.SourceVersion)")
echo "##vso[task.setvariable variable=digest;isOutput=true]${ref#*@}"
name: resolve
displayName: Resolve digest
- task: Notation@0
displayName: Install Notation
inputs:
command: install
version: "1.3.2"
- task: Notation@0
displayName: Sign the digest
inputs:
command: sign
artifactRefs: $(image)@$(resolve.digest)
plugin: azureKeyVault
akvPluginVersion: "1.2.1"
azurekvServiceConection: sc-contoso-signing # input name is spelled this way
keyid: $(signingKeyId)
selfSigned: false # true only for a self-signed test certificate
- publish: $(Build.ArtifactStagingDirectory)/sbom
artifact: sbom
- Scan before push. A vulnerable image never reaches the registry, so nothing downstream can pick it up by accident.
- Sign the digest.
$(resolve.digest)is the content hash the registry assigned. Signing a tag would sign whatever the tag points to at that moment. - Keep the SBOM. It is published as a pipeline artifact here. To store it next to the image, attach it to the digest in the registry with ORAS.
Step 6Deploy through environments with checks
A deployment job that targets an environment cannot start until every check on that environment, and on every resource the job uses, has passed. This is where Azure DevOps is strongest: the gate lives on the resource, not in the YAML that a pull request could edit.
Check on production | What it enforces |
|---|---|
| Approvals | A named group approves; turn off "Allow approvers to approve their own runs". |
| Branch control | Only runs from refs/heads/main, with branch protection verified. |
| Required template | Only pipelines extending secure-pipeline.yml@templates at the approved tag. |
| Exclusive lock | One deployment at a time; pair with lockBehavior: sequential. |
| Business hours | Optional: no production changes outside agreed hours. |
| Query Azure Monitor alerts | Optional: block while production has active alerts. |
- stage: Deploy
dependsOn: Package
lockBehavior: sequential
jobs:
- deployment: production
environment: production # approvals and checks live on the environment
pool:
vmImage: ubuntu-24.04
variables:
digest: $[ stageDependencies.Package.image.outputs['resolve.digest'] ]
imageRef: contosoacr.azurecr.io/${{ parameters.appName }}@$(digest)
strategy:
runOnce:
deploy:
steps:
- checkout: templates # trust policy comes from the platform repo
- task: Docker@2
inputs:
command: login
containerRegistry: acr-contoso
- task: Notation@0
inputs:
command: install
version: "1.3.2"
- task: Notation@0
displayName: Verify signature before deploy
inputs:
command: verify
artifactRefs: $(imageRef)
trustPolicy: $(Build.SourcesDirectory)/notation/trustpolicy.json
trustStore: $(Build.SourcesDirectory)/notation/truststore/
- task: AzureCLI@2
displayName: Deploy the verified digest
inputs:
azureSubscription: sc-contoso-prod # workload identity federation
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az containerapp update \
--name ${{ parameters.appName }} \
--resource-group rg-payments-prod \
--image "$(imageRef)"
The trust policy and trust store live in the platform repository, so an application team cannot add its own certificate and sign its own images into production.
Pitfalls that get pipelines owned
Macro injection
Macro syntax $(var) is replaced in the script text before the shell runs it. Branch names may legally contain ;, $, | and backticks, so a branch name can become a command. Map untrusted values into environment variables instead.
- script: echo "Validating $(System.PullRequest.SourceBranch)"
- script: echo "Validating $SOURCE_BRANCH"
env:
SOURCE_BRANCH: $(System.PullRequest.SourceBranch)
Logging command injection
Any line a step prints that starts with ##vso[ is a command to the agent, including task.setvariable. A test that prints attacker-controlled output can set variables for later steps. The template's target: commands: restricted and settableVariables: none block this for caller steps.
Unpinned templates
A resources.repositories entry without ref follows the default branch, so a template change takes effect in every pipeline immediately. Always pin to a tag, and protect tag creation in the templates repository.
The job access token
System.AccessToken is not exposed to scripts unless you map it in env. Map it only where a step really needs it, and keep "Limit job authorization scope" on so it cannot reach other projects.
Complete files
# Owned by platform-security. Applications extend this template;
# the required template check on production resources enforces it.
parameters:
- name: appName
type: string
- name: languages
type: string
default: javascript
- name: buildSteps
type: stepList
default: []
variables:
- name: signingKeyId
value: https://contoso-signing.vault.azure.net/keys/image-signing/REPLACE_WITH_KEY_VERSION
stages:
- stage: Build
displayName: Build, test and scan
jobs:
- job: build
pool:
vmImage: ubuntu-24.04
timeoutInMinutes: 30
steps:
- checkout: self
fetchDepth: 1
- task: AdvancedSecurity-Codeql-Init@1
inputs:
enableAutomaticCodeQLInstall: true
languages: ${{ parameters.languages }}
querysuite: security-extended
buildtype: None
- ${{ each step in parameters.buildSteps }}:
- ${{ each pair in step }}:
${{ if and(eq(pair.key, 'task'), not(in(pair.value, 'NodeTool@0', 'UseDotNet@2', 'DotNetCoreCLI@2', 'Npm@1'))) }}:
'Task ${{ pair.value }} is not on the platform allowlist': error
${{ else }}:
${{ pair.key }}: ${{ pair.value }}
target:
commands: restricted
settableVariables: none
- task: AdvancedSecurity-Dependency-Scanning@1
- task: AdvancedSecurity-Codeql-Analyze@1
- script: |
mkdir -p "$(Build.ArtifactStagingDirectory)/sarif"
docker run --rm \
-v "$(Build.SourcesDirectory):/src:ro" \
-v "$(Build.ArtifactStagingDirectory)/sarif:/out" \
aquasec/trivy:0.74.0 config /src \
--severity CRITICAL,HIGH --exit-code 1 \
--format sarif --output /out/trivy-iac.sarif
displayName: IaC scan (Trivy, blocking)
- task: AdvancedSecurity-Publish@1
condition: succeededOrFailed()
inputs:
SarifsInputDirectory: $(Build.ArtifactStagingDirectory)/sarif
Category: trivy-iac
- stage: Package
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- job: image
pool:
vmImage: ubuntu-24.04
variables:
image: contosoacr.azurecr.io/${{ parameters.appName }}
steps:
- checkout: self
- task: Docker@2
inputs:
command: login
containerRegistry: acr-contoso
- task: Docker@2
inputs:
command: build
repository: ${{ parameters.appName }}
containerRegistry: acr-contoso
tags: $(Build.SourceVersion)
- script: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:0.74.0 image \
--severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 \
"$(image):$(Build.SourceVersion)"
displayName: Image scan (release gate)
- script: |
mkdir -p "$(Build.ArtifactStagingDirectory)/sbom"
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v "$(Build.ArtifactStagingDirectory)/sbom:/out" \
anchore/syft:v1.54.1 "docker:$(image):$(Build.SourceVersion)" \
-o spdx-json=/out/sbom.spdx.json
displayName: Generate SBOM
- task: Docker@2
inputs:
command: push
repository: ${{ parameters.appName }}
containerRegistry: acr-contoso
tags: $(Build.SourceVersion)
- script: |
ref=$(docker inspect --format '{{index .RepoDigests 0}}' "$(image):$(Build.SourceVersion)")
echo "##vso[task.setvariable variable=digest;isOutput=true]${ref#*@}"
name: resolve
displayName: Resolve digest
- task: Notation@0
inputs:
command: install
version: "1.3.2"
- task: Notation@0
inputs:
command: sign
artifactRefs: $(image)@$(resolve.digest)
plugin: azureKeyVault
akvPluginVersion: "1.2.1"
azurekvServiceConection: sc-contoso-signing
keyid: $(signingKeyId)
selfSigned: false
- publish: $(Build.ArtifactStagingDirectory)/sbom
artifact: sbom
- stage: Deploy
dependsOn: Package
lockBehavior: sequential
jobs:
- deployment: production
environment: production
pool:
vmImage: ubuntu-24.04
variables:
digest: $[ stageDependencies.Package.image.outputs['resolve.digest'] ]
imageRef: contosoacr.azurecr.io/${{ parameters.appName }}@$(digest)
strategy:
runOnce:
deploy:
steps:
- checkout: templates
- task: Docker@2
inputs:
command: login
containerRegistry: acr-contoso
- task: Notation@0
inputs:
command: install
version: "1.3.2"
- task: Notation@0
inputs:
command: verify
artifactRefs: $(imageRef)
trustPolicy: $(Build.SourcesDirectory)/notation/trustpolicy.json
trustStore: $(Build.SourcesDirectory)/notation/truststore/
- task: AzureCLI@2
inputs:
azureSubscription: sc-contoso-prod
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az containerapp update \
--name ${{ parameters.appName }} \
--resource-group rg-payments-prod \
--image "$(imageRef)"
trigger:
branches:
include: [main]
resources:
repositories:
- repository: templates
type: git
name: Platform/pipeline-templates
ref: refs/tags/v2.3.0
extends:
template: secure-pipeline.yml@templates
parameters:
appName: payments-api
languages: javascript
buildSteps:
- task: NodeTool@0
inputs:
versionSpec: "22.x"
- script: npm ci --ignore-scripts
displayName: Install dependencies
- script: npm test
displayName: Unit tests
{
"version": "1.0",
"trustPolicies": [
{
"name": "contoso-production-images",
"registryScopes": [ "contosoacr.azurecr.io/payments-api" ],
"signatureVerification": { "level": "strict" },
"trustStores": [ "ca:contoso" ],
"trustedIdentities": [ "x509.subject: CN=Contoso Image Signing, O=Contoso, C=US" ]
}
]
}
Checked
Task names and inputs follow the Microsoft task reference as of 11 October 2026. Notation 1.3.2 and the Azure Key Vault plugin 1.2.1 are the latest upstream releases on that date.
References
- Securing Azure Pipelines
- Security through templates
- Approvals and checks
- Configure GitHub Advanced Security for Azure DevOps
- AdvancedSecurity-Codeql-Init@1 reference
- Sign images with Notation in Azure Pipelines
Next: the same pipeline on GitLab CI/CD or the hardening checklist.