---
title: "Force Security Scans with Azure DevOps Extends Templates"
description: "Enforce Azure DevOps security scans with an extends template that fails the build and a Required template check on the service connection."
canonical: "https://adamtheautomator.com/force-security-scans-azure-devops-templates/"
---

# Force Security Scans with Azure DevOps Extends Templates

> Enforce Azure DevOps security scans with an extends template that fails the build and a Required template check on the service connection.

Source: https://adamtheautomator.com/force-security-scans-azure-devops-templates/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Force Security Scans with Azure DevOps Extends Templates](https://adamtheautomator.com/wp-content/uploads/publisher/4065d9c85b2b81efa4f7fcb198951a15/ba17f0bdb02d3ab988d2a22cae0caed456e4fdcb8e356215804b14c1b3d34c38.jpg)

# Force Security Scans with Azure DevOps Extends Templates

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)2 November 202610 min. read

Categories: [DevOps](/category/devops/)

Tags:[Azure Pipelines](/tag/azure-pipelines/)[DevOps](/tag/devops/)[Security](/tag/security/)[Azure](/tag/azure/)

Table of Contents

*   [Prerequisites for Forcing a Scan](#prerequisites-for-forcing-a-scan)
*   [Why an Included Template Enforces Nothing](#why-an-included-template-enforces-nothing)
*   [Step 1: Author the Azure DevOps Extends Template](#step-1-author-the-azure-devops-extends-template)
*   [Make the Scan Stage a Hard Dependency](#make-the-scan-stage-a-hard-dependency)
*   [Wrap the Build Steps Instead of Trusting Them](#wrap-the-build-steps-instead-of-trusting-them)
*   [Step 2: Extend It From an Application Pipeline](#step-2-extend-it-from-an-application-pipeline)
*   [Keep Compile-Time and Runtime Expressions Straight](#keep-compile-time-and-runtime-expressions-straight)
*   [Step 3: Make the Scan Fail the Build](#step-3-make-the-scan-fail-the-build)
*   [Step 4: Enforce It With a Required Template Check](#step-4-enforce-it-with-a-required-template-check)
*   [Place the Check on the Credentials](#place-the-check-on-the-credentials)
*   [Step 5: Close the Bypass Routes Teams Will Find](#step-5-close-the-bypass-routes-teams-will-find)
*   [Trade the Blocklist for an Allow-List](#trade-the-blocklist-for-an-allow-list)
*   [Rolling It Out Without Stalling Delivery](#rolling-it-out-without-stalling-delivery)
*   [Verifying Enforcement and Proving It to Auditors](#verifying-enforcement-and-proving-it-to-auditors)

You added a security scanning template to the shared library, so you assume the scan runs. It does not. Teams get a file they are welcome to call, and nothing breaks when they skip it. Picture the pipeline that skipped it: a developer needs a hotfix out, the SCA step is failing on a transitive dependency nobody can patch this afternoon, and deleting four lines of YAML gets the merge through. Six weeks later an auditor asks which repositories ran the scan, and your honest answer is “the ones that felt like it.”

This tutorial replaces that convention with enforcement. You will write an extends template that owns every pipeline’s outer structure, point an application pipeline at it, and make the scan fail the build. Then you will add a Required template check to the service connection, so skipping the template means no deployment. Rollout and audit evidence come last, since enforcement you cannot prove is just a rumor.

## Prerequisites for Forcing a Scan

If you want to follow along, you’ll need:

*   An Azure DevOps project where you can create YAML pipelines and commit to Git. Two repositories make the ownership boundary obvious; two folders work.
    
*   Project Administrator on that project, because editing checks on a protected resource needs the Administrator or Creator role there, a separate grant from pipeline edit rights, as the [Azure DevOps permission reference](https://learn.microsoft.com/en-us/azure/devops/organizations/security/permissions?view=azure-devops) documents.
    
*   The [Microsoft Security DevOps extension](https://learn.microsoft.com/en-us/azure/defender-for-cloud/azure-devops-extension) if you want MSDO as the scanner. Installing an organization-level extension needs Project Collection Administrator, so plan that call.
    
*   A [service connection](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints#authorize-pipelines) to any Azure subscription if you want to watch the gate reject a real deployment. The check works on an environment without one.
    

Extends templates, checks, and branch policies ship with Azure DevOps Services. Tested on Azure DevOps Services in October 2026 with `MicrosoftSecurityDevOps@1` on `windows-latest`.

## Why an Included Template Enforces Nothing

An included YAML pipeline template is a compiler-assisted copy and paste. The `template:` keyword is valid inside `stages`, `jobs`, `steps`, and `variables`, and the included file lands there. That makes it excellent at removing duplication and useless at mandating anything. The consuming pipeline keeps ownership of its outer structure, so it can put steps before the include, after it, or nowhere.

| Attribute | Include template | Extends template |
| --- | --- | --- |
| Who controls the outer structure | The application pipeline | The template author |
| Where custom steps may go | Anywhere in the file | Only at declared parameter points |
| What happens when a team ignores it | Nothing | No access to protected resources |

An [extends template](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/templates?view=azure-devops) inverts that relationship. It defines [the pipeline skeleton](https://adamtheautomator.com/azure-pipelines/): stages, order, and the slots where a team may inject build steps, while the application file becomes a parameter payload. Microsoft’s security guidance states the intent plainly: “For the most secure pipelines, start by using `extends` templates. These templates define the outer structure of the pipeline and help prevent malicious code infiltration.”

A compiled pipeline is bounded too. The [imposed limits on template updates](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/templates?view=azure-devops#imposed-limits-on-template-updates) reject a run with more than 100 separate YAML files, more than 100 levels of template nesting, or more than 20 MB of YAML parse memory, so nested includes stop at a fixed depth.

The catch is that extends gives you structure, not compliance. A team can still write a fresh pipeline that never mentions your template. Enforcement needs a second mechanism, which Step 4 supplies.

## Step 1: Author the Azure DevOps Extends Template

Create `templates/security-extends.yml` in the platform repository. The file must be a valid pipeline on its own, because every consuming pipeline inherits its stages, as the [extends schema reference](https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/extends?view=azure-pipelines) requires. Start with parameters, the only surface a team may touch.

```yaml
# templates/security-extends.yml
parameters:
- name: buildPool
  type: string
  default: Azure Pipelines
  values:
  - Azure Pipelines
  - private-pool-1
- name: buildSteps
  type: stepList
  default: []

stages:
- stage: Security
  jobs:
  - job: scan
    pool:
      vmImage: windows-latest
    steps:
    - task: MicrosoftSecurityDevOps@1
      displayName: 'Scan IaC and containers'
      inputs:
        categories: 'IaC,containers'
        break: true
        publish: true
        artifactName: 'CodeAnalysisLogs'
```

A string parameter constrained by `values` rejects any agent pool you did not list, so nobody points the build at an unvetted pool, and the scan job runs on a Microsoft-hosted `windows-latest` image rather than a team’s pool. `stepList` is the typed hole where a team’s steps go. Template parameters become constants before the pipeline runs, so a step that does not match the declared shape fails compilation rather than at deployment.

### Make the Scan Stage a Hard Dependency

The build stage depends on the scan. `succeeded()` is already the default condition, and writing it out keeps the intent visible after the next well-meaning edit.

```yaml
- stage: Build
  dependsOn: Security
  condition: succeeded()
```

A failed scan job stops the pipeline before anything compiles. Scan-after-build still blocks a deployment, but it burns build minutes first and leaves you guessing which artifact was rejected.

### Wrap the Build Steps Instead of Trusting Them

Iterate the team’s steps, pass every key through untouched, and reject task types that hand arbitrary code execution to whoever wrote the YAML.

```yaml
  jobs:
  - job: build
    pool: ${{ parameters.buildPool }}
    steps:
    - script: 'echo "Base: pre-build"'
      displayName: 'Base: Pre-build'
    - ${{ each step in parameters.buildSteps }}:
      - ${{ each pair in step }}:
          ${{ if notIn(pair.value, 'CmdLine@2', 'PowerShell@2', 'Bash@3') }}:
            ${{ pair.key }}: ${{ pair.value }}
          ${{ if in(pair.value, 'CmdLine@2', 'PowerShell@2', 'Bash@3') }}:
            '${{ pair.value }}': error
    - script: 'echo "Base: post-build"'
      displayName: 'Base: Post-build'
```

That `'${{ pair.value }}': error` line is legal YAML and an illegal step. Azure hands a `- script:` step to the loop as a `CmdLine@2` task, so blocking one writes a property named `CmdLine@2` with the value `error`. The validator rejects the file at the schema level with `Unexpected value 'CmdLine@2'`, and compilation stops before an agent picks up the job, which is the right outcome for a pipeline running shell commands through governance. Microsoft’s [restrict-by-task-type example](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/templates?view=azure-devops#extend-from-a-template) runs the same loop to reject a `CmdLine@2` step.

Here is the whole file, with the indentation resolved:

```yaml
# templates/security-extends.yml
parameters:
- name: buildPool
  type: string
  default: Azure Pipelines
  values:
  - Azure Pipelines
  - private-pool-1
- name: buildSteps
  type: stepList
  default: []

stages:
- stage: Security
  jobs:
  - job: scan
    pool:
      vmImage: windows-latest
    steps:
    - task: MicrosoftSecurityDevOps@1
      displayName: 'Scan IaC and containers'
      inputs:
        categories: 'IaC,containers'
        break: true
        publish: true
        artifactName: 'CodeAnalysisLogs'
- stage: Build
  dependsOn: Security
  condition: succeeded()
  jobs:
  - job: build
    pool: ${{ parameters.buildPool }}
    steps:
    - script: 'echo "Base: pre-build"'
      displayName: 'Base: Pre-build'
    - ${{ each step in parameters.buildSteps }}:
      - ${{ each pair in step }}:
          ${{ if notIn(pair.value, 'CmdLine@2', 'PowerShell@2', 'Bash@3') }}:
            ${{ pair.key }}: ${{ pair.value }}
          ${{ if in(pair.value, 'CmdLine@2', 'PowerShell@2', 'Bash@3') }}:
            '${{ pair.value }}': error
    - script: 'echo "Base: post-build"'
      displayName: 'Base: Post-build'
```

That compiles to two stages, `Security` then `Build`, with `buildSteps` between the base steps.

![Compiled pipeline stages](https://adamtheautomator.com/wp-content/uploads/publisher/acf34a8d499dd970366c9e19e7b53bcc3062907a86ac375a219e1efc270a2803.jpg)

* * *

_**Warning: Compile-time rejection is unforgiving by design. Once Bash@3 and CmdLine@2 are blocked, a team that needs a shell step has no workaround inside its own YAML. Expose the operations they need as named tasks in the template, or the first blocked pipeline will be followed by a request to delete the check.**_

* * *

## Step 2: Extend It From an Application Pipeline

The application repository holds a pipeline that is mostly a parameter payload. Everything structural lives in the template it references.

```yaml
# azure-pipelines.yml
trigger:
- main

resources:
  repositories:
  - repository: templates
    type: git
    name: Platform/PlatformTemplates
    ref: refs/tags/v1.4.0

extends:
  template: templates/security-extends.yml@templates
  parameters:
    buildPool: private-pool-1
    buildSteps:
    - task: NodeTool@0
      inputs:
        versionSpec: '22.x'
    - task: Npm@1
      displayName: 'Restore packages'
      inputs:
        command: ci
    - task: Npm@1
      displayName: 'Run tests'
      inputs:
        command: custom
        customCommand: test
```

Every step in that payload is a named task on purpose. `script` is the YAML shortcut for `CmdLine@2`, so a `- script:` line reaches the loop as the task type it blocks and fails compilation.

Three details carry the value. The `@templates` suffix resolves through the `resources.repositories` alias, the way a pipeline reaches a template in another repository. The `ref` pins a tag, so a bad template commit cannot change every pipeline the moment it merges. And `trigger` stays in the consumer file, because a trigger declared inside an extends template is ignored without warning.

### Keep Compile-Time and Runtime Expressions Straight

The `${{ }}` syntax above is a compile-time expression, evaluated when the YAML becomes a plan, with access to `parameters` and [statically defined variables](https://adamtheautomator.com/azure-devops-variables/). The `$[ ]` syntax is a runtime expression, evaluated during the run with more variables available and no access to parameters. A `$[ ]` expression inside a parameter block will not work as expected, because the value was already resolved to a constant.

`templateContext` passes extra properties into jobs a template consumes as parameters. Declare the parameter as `jobList`, then read `${{ job.templateContext.<property> }}`, as the [template parameters documentation](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/template-parameters?view=azure-devops) shows. That gives you per-job metadata without a free-form variable a pipeline author could overwrite.

## Step 3: Make the Scan Fail the Build

A scanner that publishes findings and exits 0 is a report, and reports get ignored. MSDO decides this with one input; the analyzer categories decide what that input catches.

| Input | What it controls | Why pin it in the template |
| --- | --- | --- |
| `categories` | Analyzer families: `code`, `artifacts`, `IaC`, `containers` | Teams scope down to the category that passes fastest |
| `break` | Fails the step on high severity results | Defaults to `false`, so an unpinned scan never blocks |
| `tools` | Which analyzers run, such as `checkov` or `trivy` | Leaves a gap when a team disables the slow scanner |

The `break` input decides whether the scan blocks anything at all. It defaults to `false`, so a template that omits the input produces a green build with critical findings attached. `break: true` fails the step on any high severity result, and the Step 1 condition turns that into a stopped pipeline.

* * *

_**Reality Check: `break: true` reacts to high severity findings only. A medium-severity container vulnerability will not fail the step. If your policy says medium blocks a release, the scan step is not your enforcement point and a policy evaluation has to carry that rule.**_

* * *

One accuracy note older guides miss: MSDO’s built-in secrets scanning through CredScan is deprecated, replaced by [GitHub Advanced Security for Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/repos/security/configure-github-advanced-security-features?view=azure-devops). Settings beyond these defaults are documented in the [Microsoft Security DevOps wiki](https://github.com/microsoft/security-devops-action/wiki). A scan that fails the build still governs only the pipelines that call the template, so Step 4 adds the check for the rest.

## Step 4: Enforce It With a Required Template Check

The Required template check is what turns the template into a rule. Microsoft’s [checks documentation](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals?view=azure-devops) puts the behavior in one sentence: “With the required template check, you can enforce pipelines to use a specific YAML template. When this check is in place, a pipeline fails if it doesn’t extend from the referenced template.”

The check sits on a protected resource, so a pipeline author editing the YAML cannot modify what runs before a stage starts.

### Place the Check on the Credentials

Put the Required template check on the service connection that holds your deployment credentials, because the credentials are the actual boundary.

1.  Open the project, then **Pipelines** > **Service connections**, and select the connection to protect.
    
2.  Choose **Approvals and Checks** from the menu next to **Edit**.
    
3.  Select **Required template**, then fill in **Repository type**, **Repository**, **Ref**, and **Path to required template**. For this build: Azure Repos, `Platform/PlatformTemplates`, `refs/tags/v1.4.0`, `templates/security-extends.yml`.
    
4.  Save the check and confirm it appears.
    

Static checks run first, alongside branch control and artifact evaluation, so a non-compliant pipeline fails before an approver is paged. Several required templates can sit on one connection, so you can mandate a security base and a deployment wrapper separately. The panel below shows the completed fields for this build.

![Required template check](https://adamtheautomator.com/wp-content/uploads/publisher/1cf3919001c8e86ff65f773c73ff62599ce99cd78b4ab157b777da75a24f6db2.jpg)

## Step 5: Close the Bypass Routes Teams Will Find

Enforcement holds for about a week before someone finds a way around it. Each bypass has a control that closes it.

| Bypass attempt | Control that closes it |
| --- | --- |
| A pipeline that never mentions the template | Required template check on the service connection |
| A shell step smuggled into the step list | Blocked task types in the compile-time loop |
| A task you never approved | Allow-list on `step.task` instead of a blocklist |
| Pointing at an unvetted agent pool | `values` list on the pool parameter |
| Editing the template directly on `main` | Branch policies with reviewers and build validation |
| A new service connection to dodge the check | Restrict who can create service connections |

### Trade the Blocklist for an Allow-List

The allow-list version inverts the test onto approved task names. `in` and `notIn` are documented [expression functions](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/expressions?view=azure-devops), so the shape stays the same.

```yaml
    - ${{ each step in parameters.buildSteps }}:
      - ${{ if notIn(step.task, 'NodeTool@0', 'Npm@1', 'DotNetCoreCLI@2', 'PublishBuildArtifacts@1') }}:
        - '${{ step.task }}': error
      - ${{ else }}:
        - ${{ step }}
```

Two holes survive an allow-list. It tests `step.task` by name, so a step written as `- script: |` is judged by the task name Azure assigns it rather than the shell key it carries; test `pair.key` against `script`, `bash`, `pwsh`, and `powershell` to force teams onto named tasks. And an approved task with a dangerous input is still approved: `DotNetCoreCLI@2` runs whatever `arguments` string a team hands it. Treat the allow-list as one control among several rather than complete coverage.

* * *

_**Key Insight: Protect the template repository like production. The check verifies that a pipeline extends a template at a pinned ref, so whoever can merge to that tag controls every governed pipeline. Require two reviewers who are not the author, require a linked work item, and run sample consumer pipelines against every change.**_

* * *

## Rolling It Out Without Stalling Delivery

Do not flip the check onto your production service connection on a Monday and go to lunch. Stand up a second connection that only the pilot team can use, point a handful of representative pipelines at the template, and leave the old connection ungoverned. Coverage grows one reversible migration at a time.

Pin the template by tag and bump the tag on purpose. A team on `v1.4.0` keeps working while `v1.5.0` is tested against the pilot set, which turns a breaking change into a scheduled event. The template repository itself needs the standard protections: a minimum reviewer count, no self-approval, comment resolution, and [build validation](https://learn.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops) so a syntax error never reaches a tag. Grandfather what cannot move this quarter in a written exception list with an owner and an expiry date, where auditors can read it.

## Verifying Enforcement and Proving It to Auditors

Test the gate the way an attacker would. Create a throwaway pipeline that deploys to the protected resource without an `extends` block, run it, and confirm the stage fails at the check rather than at the deployment. Then create one that extends the template and confirm it passes. Two runs tell you whether the rule is real.

For the audit file, collect three artifacts. The [check configuration](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals?view=azure-devops) on the service connection is the rule itself, and it lives outside YAML where a pipeline author cannot edit it. Run history shows the checks panel for each deployment, including bypasses, which need administrator permission and are recorded against the identity that used them. [The `CodeAnalysisLogs` artifact from each scan is evidence that a scan ran](https://adamtheautomator.com/supply-chain-security-azure-devops/), and [Defender for Cloud](https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-cloud-introduction) ingests that artifact for continuous visibility into repositories with unresolved findings.

Do that and the auditor’s question changes shape. It stops being “did anyone skip the scan” and becomes “which exception is still open and who owns it,” which you can answer from a list instead of a memory.

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fforce-security-scans-azure-devops-templates%2F&text=Force%20Security%20Scans%20with%20Azure%20DevOps%20Extends%20Templates)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fforce-security-scans-azure-devops-templates%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fforce-security-scans-azure-devops-templates%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/publisher/4065d9c85b2b812bbb79d18090b8cf1a/5c974fd06f45af85a7518ad43dc562c6698da0193a329039cdfb527daac90167.webp)

### [Azure DevOps: Fix "Acquiring an agent from the cloud"](/acquiring-agent-from-cloud-azure-devops/)

Stuck on "Acquiring an agent from the cloud"? Check pool saturation, parallel job ceilings, VNet permissions, and blocked egress before editing YAML.

![](https://adamtheautomator.com/wp-content/uploads/2026/03/featured_image.webp)

### [Prove Every Artifact: Supply Chain Security in Azure DevOps](/supply-chain-security-azure-devops/)

Learn to implement software supply chain security in Azure DevOps with SBOM generation, artifact signing, dependency scanning, and deployment gate enforcement.

![](https://adamtheautomator.com/wp-content/uploads/publisher/2e05d9c85b2b8187b503fb0f6ceff47e/e15d54857176da3fe72b1f0692faaa7000367a5fbe6edf5f6a5f1034395fea45.webp)

### [Azure Bicep: Bulletproof Production Deployment Patterns](/azure-bicep-production-patterns/)

Own your Bicep blast radius: pin modules in a private registry, gate pull requests with lint and PSRule, and track production changes with stacks.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
