BYOWBuild Your Own Workflow

Part 2b · Azure DevOps

A secure pipeline on Azure DevOps

Lock down the organization, move every Azure credential to workload identity federation, and force every pipeline through a protected template that scans, signs and deploys only through environments with approvals and checks.

~45 min YAML pipelines Advanced Security tasks v1 Notation 1.3.2 Required template check

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.

Disable creation of classic build and release pipelines Classic pipelines cannot use templates, are not stored in Git, and bypass code review. New work goes into YAML.
Limit job authorization scope to the current project (release and non-release) The job access token can then reach only its own project, not every project in the organization.
Protect access to repositories in YAML pipelines A pipeline must be explicitly granted access to each repository it checks out, so one compromised pipeline cannot read every repo.
Limit variables that can be set at queue time Only variables marked "settable at queue time" can be overridden when someone queues a run, which closes a common injection path.
Enable shell tasks arguments validation Built-in shell tasks validate their arguments for command injection characters.
Pull requests from forks Pipeline›Edit›Triggers›Pull request validation For GitHub-hosted code, leave "Make secrets available to builds of forks" off and require a team member's comment before building a fork's pull request.

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 connectionUsed byAzure role, scopeChecks
acr-contosoPackage stage (push)AcrPush on contosoacrBranch control: refs/heads/main
sc-contoso-signingNotation signKey Vault Crypto User and Certificate User on the signing vaultBranch control, required template
sc-contoso-prodDeploy stageContributor on rg-payments-prod onlyBranch 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:

mapping a secret into one stepyaml
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.

azure-pipelines.yml (payments-api repo)yaml
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.

secure-pipeline.yml · parameters and step guardyaml
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.

secure-pipeline.yml · Build stageyaml
- 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.

open-source scan stepsyaml
- 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.

secure-pipeline.yml · Package stageyaml
- 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 productionWhat it enforces
ApprovalsA named group approves; turn off "Allow approvers to approve their own runs".
Branch controlOnly runs from refs/heads/main, with branch protection verified.
Required templateOnly pipelines extending secure-pipeline.yml@templates at the approved tag.
Exclusive lockOne deployment at a time; pair with lockBehavior: sequential.
Business hoursOptional: no production changes outside agreed hours.
Query Azure Monitor alertsOptional: block while production has active alerts.
secure-pipeline.yml · Deploy stageyaml
- 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.

Injectableyaml
- script: echo "Validating $(System.PullRequest.SourceBranch)"
Safeyaml
- 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

Platform/pipeline-templates/secure-pipeline.ymlyaml
# 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)"

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

Next: the same pipeline on GitLab CI/CD or the hardening checklist.